From c6ff79aa0e4d58503cbbecae8635da7382d883cf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Feb 2023 14:58:11 -0500 Subject: [PATCH 0001/1058] Error checking for OTBR (#88620) * Error checking for OTBR * Other errors in flow too * Tests --- homeassistant/components/otbr/__init__.py | 10 +++++-- homeassistant/components/otbr/config_flow.py | 8 ++++- tests/components/otbr/test_config_flow.py | 31 ++++++++++++++++++++ tests/components/otbr/test_init.py | 20 +++++++++---- 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 19eaa55f00e1..ebe2ab002577 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -1,11 +1,13 @@ """The Open Thread Border Router integration.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Coroutine import dataclasses from functools import wraps from typing import Any, Concatenate, ParamSpec, TypeVar +import aiohttp import python_otbr_api from homeassistant.components.thread import async_add_dataset @@ -63,8 +65,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: otbrdata = OTBRData(entry.data["url"], api) try: dataset = await otbrdata.get_active_dataset_tlvs() - except HomeAssistantError as err: - raise ConfigEntryNotReady from err + except ( + HomeAssistantError, + aiohttp.ClientError, + asyncio.TimeoutError, + ) as err: + raise ConfigEntryNotReady("Unable to connect") from err if dataset: await async_add_dataset(hass, entry.title, dataset.hex()) diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 1d54084969b9..00aae5b8a078 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -1,8 +1,10 @@ """Config flow for the Open Thread Border Router integration.""" from __future__ import annotations +import asyncio import logging +import aiohttp import python_otbr_api import voluptuous as vol @@ -48,7 +50,11 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): url = user_input[CONF_URL] try: await self._connect_and_create_dataset(url) - except python_otbr_api.OTBRError: + except ( + python_otbr_api.OTBRError, + aiohttp.ClientError, + asyncio.TimeoutError, + ): errors["base"] = "cannot_connect" else: await self.async_set_unique_id(DOMAIN) diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 918d15046537..a3cdefe2b756 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -1,8 +1,11 @@ """Test the Open Thread Border Router config flow.""" +import asyncio from http import HTTPStatus from unittest.mock import patch +import aiohttp import pytest +import python_otbr_api from homeassistant.components import hassio, otbr from homeassistant.core import HomeAssistant @@ -137,6 +140,34 @@ async def test_user_flow_404( assert result["errors"] == {"base": "cannot_connect"} +@pytest.mark.parametrize( + "error", + [ + asyncio.TimeoutError, + python_otbr_api.OTBRError, + aiohttp.ClientError, + ], +) +async def test_user_flow_connect_error(hass: HomeAssistant, error) -> None: + """Test the user flow.""" + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch("python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=error): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "http://custom_url:1234", + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + async def test_hassio_discovery_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 10affab07868..7818d736e0e8 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,9 +1,11 @@ """Test the Open Thread Border Router integration.""" - +import asyncio from http import HTTPStatus from unittest.mock import patch +import aiohttp import pytest +import python_otbr_api from homeassistant.components import otbr from homeassistant.core import HomeAssistant @@ -35,9 +37,15 @@ async def test_import_dataset(hass: HomeAssistant) -> None: mock_add.assert_called_once_with(config_entry.title, DATASET.hex()) -async def test_config_entry_not_ready( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@pytest.mark.parametrize( + "error", + [ + asyncio.TimeoutError, + python_otbr_api.OTBRError, + aiohttp.ClientError, + ], +) +async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: """Test raising ConfigEntryNotReady .""" config_entry = MockConfigEntry( @@ -47,8 +55,8 @@ async def test_config_entry_not_ready( title="My OTBR", ) config_entry.add_to_hass(hass) - aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CREATED) - assert not await hass.config_entries.async_setup(config_entry.entry_id) + with patch("python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=error): + assert not await hass.config_entries.async_setup(config_entry.entry_id) async def test_remove_entry( From 2a819f23c1fe2a374845e992082e3c130fec8b78 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 22 Feb 2023 15:12:55 -0500 Subject: [PATCH 0002/1058] Disable the ZHA bellows UART thread when connecting to a TCP coordinator (#88202) Disable the bellows UART thread when connecting to a TCP coordinator --- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/gateway.py | 11 ++++++ tests/components/zha/test_gateway.py | 36 ++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 8a773213a58a..4c10a2328a27 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -139,6 +139,7 @@ CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" 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 128e3b145f1c..2f1b22e0ea2d 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -40,7 +40,9 @@ from .const import ( ATTR_SIGNATURE, ATTR_TYPE, CONF_DATABASE, + CONF_DEVICE_PATH, CONF_RADIO_TYPE, + CONF_USE_THREAD, CONF_ZIGPY, DATA_ZHA, DATA_ZHA_BRIDGE_ID, @@ -167,6 +169,15 @@ class ZHAGateway: app_config[CONF_DATABASE] = database app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] + # 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 RadioType[radio_type] is RadioType.ezsp + and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") + ): + app_config[CONF_USE_THREAD] = False + app_config = app_controller_cls.SCHEMA(app_config) for attempt in range(STARTUP_RETRIES): diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index b96acb29b10c..adff43d377be 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -287,3 +287,39 @@ async def test_gateway_initialize_failure_transient( # Initialization immediately stops and is retried after TransientConnectionError assert mock_new.call_count == 2 + + +@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, thread_state, config_override, hass, coordinator +): + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + + zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) + zha_gateway.config_entry.data["device"]["path"] = device_path + zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + new=AsyncMock(), + ) as mock_new: + await zha_gateway.async_initialize() + + assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state From aa3657e0712081c835f81cf0c711854a54b90beb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Feb 2023 21:29:49 +0100 Subject: [PATCH 0003/1058] Bump version to 2023.4.0dev0 (#88630) --- .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 0c6e0c173a22..57c7425932b7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.3 + HA_SHORT_VERSION: 2023.4 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 52cb0b5fa0fa..1559560f11fa 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 3 +MINOR_VERSION: Final = 4 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index d913ad0daaf8..a3d6d2f2446f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0.dev0" +version = "2023.4.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 473db48943cc6668cc11f77a8626a6f60bd369a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Feb 2023 21:31:02 +0100 Subject: [PATCH 0004/1058] Bump python-otbr-api to 1.0.4 (#88613) * Bump python-otbr-api to 1.0.4 * Adjust tests --- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/otbr/test_config_flow.py | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 7abf716cec49..24fb89f21404 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==1.0.3"] + "requirements": ["python-otbr-api==1.0.4"] } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 89b5aa3baaec..16fadd9b06e5 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==1.0.3", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==1.0.4", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7d0e175bde33..0de24f6b3cb4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2097,7 +2097,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.3 +python-otbr-api==1.0.4 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 425c87c7a854..b1d1176f1608 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1490,7 +1490,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.3 +python-otbr-api==1.0.4 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index a3cdefe2b756..e27cfb219cf8 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -98,7 +98,7 @@ async def test_user_flow_router_not_setup( assert aioclient_mock.mock_calls[-1][0] == "POST" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" - assert aioclient_mock.mock_calls[-1][2] == "enabled" + assert aioclient_mock.mock_calls[-1][2] == "enable" expected_data = { "url": "http://custom_url:1234", @@ -230,7 +230,7 @@ async def test_hassio_discovery_flow_router_not_setup( assert aioclient_mock.mock_calls[-1][0] == "POST" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" - assert aioclient_mock.mock_calls[-1][2] == "enabled" + assert aioclient_mock.mock_calls[-1][2] == "enable" expected_data = { "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", @@ -279,7 +279,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( assert aioclient_mock.mock_calls[-1][0] == "POST" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" - assert aioclient_mock.mock_calls[-1][2] == "enabled" + assert aioclient_mock.mock_calls[-1][2] == "enable" expected_data = { "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", From 87dc692a2074849cd314bf5ee7199b7b14efccf3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Feb 2023 22:01:32 +0100 Subject: [PATCH 0005/1058] Use json_loads_object in alexa (#88610) --- homeassistant/components/alexa/state_report.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 783397ca0479..a189c364c02d 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -5,6 +5,7 @@ import asyncio from http import HTTPStatus import json import logging +from typing import cast import aiohttp import async_timeout @@ -15,6 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.significant_change import create_checker import homeassistant.util.dt as dt_util +from homeassistant.util.json import JsonObjectType, json_loads_object from .const import API_CHANGE, DATE_FORMAT, DOMAIN, Cause from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id @@ -162,9 +164,10 @@ async def async_send_changereport_message( if response.status == HTTPStatus.ACCEPTED: return - response_json = json.loads(response_text) + response_json = json_loads_object(response_text) + response_payload = cast(JsonObjectType, response_json["payload"]) - if response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION": + if response_payload["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION": if invalidate_access_token: # Invalidate the access token and try again config.async_invalidate_access_token() @@ -180,8 +183,8 @@ async def async_send_changereport_message( _LOGGER.error( "Error when sending ChangeReport for %s to Alexa: %s: %s", alexa_entity.entity_id, - response_json["payload"]["code"], - response_json["payload"]["description"], + response_payload["code"], + response_payload["description"], ) @@ -299,11 +302,12 @@ async def async_send_doorbell_event_message(hass, config, alexa_entity): if response.status == HTTPStatus.ACCEPTED: return - response_json = json.loads(response_text) + response_json = json_loads_object(response_text) + response_payload = cast(JsonObjectType, response_json["payload"]) _LOGGER.error( "Error when sending DoorbellPress event for %s to Alexa: %s: %s", alexa_entity.entity_id, - response_json["payload"]["code"], - response_json["payload"]["description"], + response_payload["code"], + response_payload["description"], ) From 23b52025f987e3973e107d35dfdc60273cf57cae Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 23 Feb 2023 08:13:03 +0100 Subject: [PATCH 0006/1058] Bump reolink-aio to 0.5.1 and check if update supported (#88641) --- homeassistant/components/reolink/__init__.py | 3 +++ homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/update.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 2d3fc52eb30a..6633f5c02f20 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -79,6 +79,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_check_firmware_update(): """Check for firmware updates.""" + if not host.api.supported(None, "update"): + return False + async with async_timeout.timeout(host.api.timeout): try: return await host.api.check_new_firmware() diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index afba72fbf10c..62b2b5a038e5 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.0"] + "requirements": ["reolink-aio==0.5.1"] } diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 71ca16ca68d0..51a969771723 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -30,7 +30,8 @@ async def async_setup_entry( ) -> None: """Set up update entities for Reolink component.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([ReolinkUpdateEntity(reolink_data)]) + if reolink_data.host.api.supported(None, "update"): + async_add_entities([ReolinkUpdateEntity(reolink_data)]) class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): diff --git a/requirements_all.txt b/requirements_all.txt index 0de24f6b3cb4..80c7bedb6d0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.0 +reolink-aio==0.5.1 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1d1176f1608..4583dbae11d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1585,7 +1585,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.0 +reolink-aio==0.5.1 # homeassistant.components.python_script restrictedpython==6.0 From 27ebee1501bbb591265c54068a3f07d6fa55ab47 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Feb 2023 02:18:55 -0500 Subject: [PATCH 0007/1058] Fix untrapped exceptions during Yale Access Bluetooth first setup (#88642) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/__init__.py | 4 +++- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 64d24504ef75..718a6b571af4 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.2.7", "yalexs_ble==2.0.2"] + "requirements": ["yalexs==1.2.7", "yalexs_ble==2.0.3"] } diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index f3d086afed0b..4a937585732f 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -1,6 +1,8 @@ """The Yale Access Bluetooth integration.""" from __future__ import annotations +import asyncio + from yalexs_ble import ( AuthError, ConnectionInfo, @@ -62,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await push_lock.wait_for_first_update(DEVICE_TIMEOUT) except AuthError as ex: raise ConfigEntryAuthFailed(str(ex)) from ex - except YaleXSBLEError as ex: + except (YaleXSBLEError, asyncio.TimeoutError) as ex: raise ConfigEntryNotReady( f"{ex}; Try moving the Bluetooth adapter closer to {local_name}" ) from ex diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 1a817c8a526a..b8d9ad3d16f3 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.0.2"] + "requirements": ["yalexs-ble==2.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80c7bedb6d0d..5f857958a74f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2670,13 +2670,13 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.2 +yalexs-ble==2.0.3 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.2 +yalexs_ble==2.0.3 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4583dbae11d4..be5f3bb7f39b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1895,13 +1895,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.2 +yalexs-ble==2.0.3 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.2 +yalexs_ble==2.0.3 # homeassistant.components.yeelight yeelight==0.7.10 From 6474297d1f66e9c9bc8fcb1df55b484195b71816 Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Thu, 23 Feb 2023 11:17:46 +0300 Subject: [PATCH 0008/1058] Browse media support in universal media player (#85668) Allow forward and override browse media in universal media player --- .../components/universal/media_player.py | 56 ++++---- .../components/universal/test_media_player.py | 121 ++++++++++++++---- 2 files changed, 128 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index c7cc0dd098d3..2cbe7aa6fb14 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -45,6 +45,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, @@ -78,6 +79,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( TrackTemplate, @@ -93,6 +95,7 @@ ATTR_ACTIVE_CHILD = "active_child" CONF_ATTRS = "attributes" CONF_CHILDREN = "children" CONF_COMMANDS = "commands" +CONF_BROWSE_MEDIA_ENTITY = "browse_media_entity" STATES_ORDER = [ STATE_UNKNOWN, @@ -119,6 +122,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_ATTRS, default={}): vol.Or( cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA ), + vol.Optional(CONF_BROWSE_MEDIA_ENTITY): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_TEMPLATE): cv.template, @@ -136,17 +140,7 @@ async def async_setup_platform( """Set up the universal media players.""" await async_setup_reload_service(hass, "universal", ["media_player"]) - player = UniversalMediaPlayer( - hass, - config.get(CONF_NAME), - config.get(CONF_CHILDREN), - config.get(CONF_COMMANDS), - config.get(CONF_ATTRS), - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE_CLASS), - config.get(CONF_STATE_TEMPLATE), - ) - + player = UniversalMediaPlayer(hass, config) async_add_entities([player]) @@ -158,30 +152,25 @@ class UniversalMediaPlayer(MediaPlayerEntity): def __init__( self, hass, - name, - children, - commands, - attributes, - unique_id=None, - device_class=None, - state_template=None, + config, ): """Initialize the Universal media device.""" self.hass = hass - self._name = name - self._children = children - self._cmds = commands + self._name = config.get(CONF_NAME) + self._children = config.get(CONF_CHILDREN) + self._cmds = config.get(CONF_COMMANDS) self._attrs = {} - for key, val in attributes.items(): + for key, val in config.get(CONF_ATTRS).items(): attr = list(map(str.strip, val.split("|", 1))) if len(attr) == 1: attr.append(None) self._attrs[key] = attr self._child_state = None self._state_template_result = None - self._state_template = state_template - self._device_class = device_class - self._attr_unique_id = unique_id + self._state_template = config.get(CONF_STATE_TEMPLATE) + self._device_class = config.get(CONF_DEVICE_CLASS) + self._attr_unique_id = config.get(CONF_UNIQUE_ID) + self._browse_media_entity = config.get(CONF_BROWSE_MEDIA_ENTITY) async def async_added_to_hass(self) -> None: """Subscribe to children and template state changes.""" @@ -497,6 +486,9 @@ class UniversalMediaPlayer(MediaPlayerEntity): if SERVICE_PLAY_MEDIA in self._cmds: flags |= MediaPlayerEntityFeature.PLAY_MEDIA + if self._browse_media_entity: + flags |= MediaPlayerEntityFeature.BROWSE_MEDIA + if SERVICE_CLEAR_PLAYLIST in self._cmds: flags |= MediaPlayerEntityFeature.CLEAR_PLAYLIST @@ -628,6 +620,20 @@ class UniversalMediaPlayer(MediaPlayerEntity): # Delegate to turn_on or turn_off by default await super().async_toggle() + async def async_browse_media( + self, + media_content_type: str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Return a BrowseMedia instance.""" + entity_id = self._browse_media_entity + if not entity_id and self._child_state: + entity_id = self._child_state.entity_id + component: EntityComponent[MediaPlayerEntity] = self.hass.data[DOMAIN] + if entity_id and (entity := component.get_entity(entity_id)): + return await entity.async_browse_media(media_content_type, media_content_id) + raise NotImplementedError() + async def async_update(self) -> None: """Update state in HA.""" self._child_state = None diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 78fd07221800..81204fe21c2b 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -9,7 +9,8 @@ from homeassistant import config as hass_config import homeassistant.components.input_number as input_number import homeassistant.components.input_select as input_select import homeassistant.components.media_player as media_player -from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.media_player import MediaClass, MediaPlayerEntityFeature +from homeassistant.components.media_player.browse_media import BrowseMedia import homeassistant.components.switch as switch import homeassistant.components.universal.media_player as universal from homeassistant.const import ( @@ -36,6 +37,15 @@ CONFIG_CHILDREN_ONLY = { ], } +MOCK_BROWSE_MEDIA = BrowseMedia( + media_class=MediaClass.APP, + media_content_id="mock-id", + media_content_type="mock-type", + title="Mock Title", + can_play=False, + can_expand=True, +) + def validate_config(config): """Use the platform schema to validate configuration.""" @@ -376,7 +386,7 @@ async def test_master_state(hass: HomeAssistant) -> None: """Test master state property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.master_state is None @@ -387,7 +397,7 @@ async def test_master_state_with_attrs( """Test master state property.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.master_state == STATE_OFF hass.states.async_set(mock_states.mock_state_switch_id, STATE_ON) @@ -402,7 +412,7 @@ async def test_master_state_with_bad_attrs( config["attributes"]["state"] = "bad.entity_id" config = validate_config(config) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.master_state == STATE_OFF @@ -411,7 +421,7 @@ async def test_active_child_state(hass: HomeAssistant, mock_states) -> None: """Test active child state property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -452,7 +462,7 @@ async def test_name(hass: HomeAssistant) -> None: """Test name property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert config["name"] == ump.name @@ -461,7 +471,7 @@ async def test_polling(hass: HomeAssistant) -> None: """Test should_poll property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.should_poll is False @@ -470,7 +480,7 @@ async def test_state_children_only(hass: HomeAssistant, mock_states) -> None: """Test media player state with only children.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -489,7 +499,7 @@ async def test_state_with_children_and_attrs( """Test media player with children and master state.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -514,7 +524,7 @@ async def test_volume_level(hass: HomeAssistant, mock_states) -> None: """Test volume level property.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -538,7 +548,7 @@ async def test_media_image_url(hass: HomeAssistant, mock_states) -> None: test_url = "test_url" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -558,7 +568,7 @@ async def test_is_volume_muted_children_only(hass: HomeAssistant, mock_states) - """Test is volume muted property w/ children only.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -583,7 +593,7 @@ async def test_sound_mode_list_children_and_attr( """Test sound mode list property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.sound_mode_list == "['music', 'movie']" @@ -599,7 +609,7 @@ async def test_source_list_children_and_attr( """Test source list property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.source_list == "['dvd', 'htpc']" @@ -613,7 +623,7 @@ async def test_sound_mode_children_and_attr( """Test sound modeproperty w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.sound_mode == "music" @@ -627,7 +637,7 @@ async def test_source_children_and_attr( """Test source property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.source == "dvd" @@ -641,7 +651,7 @@ async def test_volume_level_children_and_attr( """Test volume level property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert ump.volume_level == 0 @@ -655,7 +665,7 @@ async def test_is_volume_muted_children_and_attr( """Test is volume muted property w/ children and attrs.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) assert not ump.is_volume_muted @@ -669,7 +679,7 @@ async def test_supported_features_children_only( """Test supported media commands with only children.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -709,9 +719,10 @@ async def test_supported_features_children_and_cmds( "play_media": excmd, "clear_playlist": excmd, } + config["browse_media_entity"] = "media_player.test" config = validate_config(config) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -737,6 +748,7 @@ async def test_supported_features_children_and_cmds( | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.BROWSE_MEDIA ) assert check_flags == ump.supported_features @@ -926,7 +938,7 @@ async def test_supported_features_play_pause( config["commands"] = {"media_play_pause": excmd} config = validate_config(config) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -946,7 +958,7 @@ async def test_service_call_no_active_child( """Test a service call to children with no active child.""" config = validate_config(config_children_and_attr) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -966,7 +978,7 @@ async def test_service_call_to_child(hass: HomeAssistant, mock_states) -> None: """Test service calls that should be routed to a child.""" config = validate_config(CONFIG_CHILDREN_ONLY) - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -1045,7 +1057,7 @@ async def test_service_call_to_command(hass: HomeAssistant, mock_states) -> None service = async_mock_service(hass, "test", "turn_off") - ump = universal.UniversalMediaPlayer(hass, **config) + ump = universal.UniversalMediaPlayer(hass, config) ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) await ump.async_update() @@ -1084,6 +1096,67 @@ async def test_state_template(hass: HomeAssistant) -> None: assert hass.states.get("media_player.tv").state == STATE_OFF +async def test_browse_media(hass: HomeAssistant): + """Test browse media.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + config = { + "name": "test", + "platform": "universal", + "children": [ + "media_player.bedroom", + ], + } + config = validate_config(config) + ump = universal.UniversalMediaPlayer(hass, config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + with patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", + MediaPlayerEntityFeature.BROWSE_MEDIA, + ), patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.async_browse_media", + return_value=MOCK_BROWSE_MEDIA, + ): + result = await ump.async_browse_media() + assert result == MOCK_BROWSE_MEDIA + + +async def test_browse_media_override(hass: HomeAssistant): + """Test browse media override.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + config = { + "name": "test", + "platform": "universal", + "children": [ + "media_player.mock1", + ], + "browse_media_entity": "media_player.bedroom", + } + config = validate_config(config) + ump = universal.UniversalMediaPlayer(hass, config) + ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config["name"]) + await ump.async_update() + + with patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", + MediaPlayerEntityFeature.BROWSE_MEDIA, + ), patch( + "homeassistant.components.demo.media_player.MediaPlayerEntity.async_browse_media", + return_value=MOCK_BROWSE_MEDIA, + ): + result = await ump.async_browse_media() + assert result == MOCK_BROWSE_MEDIA + + async def test_device_class(hass: HomeAssistant) -> None: """Test device_class property.""" hass.states.async_set("sensor.test_sensor", "on") From 6511b3f35590b84d4067f0d1761f33c8cc48acdb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Feb 2023 10:59:47 +0100 Subject: [PATCH 0009/1058] Update pre-commit to 3.1.0 (#88657) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 49b6450883a3..75153ea6a0a1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ coverage==7.1.0 freezegun==1.2.2 mock-open==1.4.0 mypy==1.0.1 -pre-commit==3.0.0 +pre-commit==3.1.0 pydantic==1.10.5 pylint==2.16.0 pylint-per-file-ignores==1.1.0 From dac3c7179fb7c972beb5f38d24e8db938ca86351 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Feb 2023 16:22:39 +0100 Subject: [PATCH 0010/1058] Add missing async_setup_entry mock in openuv (#88661) --- tests/components/openuv/conftest.py | 10 ++++++++++ tests/components/openuv/test_config_flow.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/tests/components/openuv/conftest.py b/tests/components/openuv/conftest.py index 7d9e8b9a4fdd..0f59c6279fb5 100644 --- a/tests/components/openuv/conftest.py +++ b/tests/components/openuv/conftest.py @@ -1,4 +1,5 @@ """Define test fixtures for OpenUV.""" +from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch @@ -20,6 +21,15 @@ TEST_LATITUDE = 51.528308 TEST_LONGITUDE = -0.3817765 +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.openuv.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="client") def client_fixture(data_protection_window, data_uv_index): """Define a mock Client object.""" diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py index 7b5a76c9ace9..ddc7d3ce85d7 100644 --- a/tests/components/openuv/test_config_flow.py +++ b/tests/components/openuv/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch from pyopenuv.errors import InvalidApiKeyError +import pytest import voluptuous as vol from homeassistant import data_entry_flow @@ -17,6 +18,8 @@ from homeassistant.core import HomeAssistant from .conftest import TEST_API_KEY, TEST_ELEVATION, TEST_LATITUDE, TEST_LONGITUDE +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_create_entry(hass: HomeAssistant, client, config, mock_pyopenuv) -> None: """Test creating an entry.""" From f8314fe0078402f034adab71d836f8f01f2d9461 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Feb 2023 16:23:03 +0100 Subject: [PATCH 0011/1058] Update apprise to 1.3.0 (#88658) --- 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 a462d433c7f0..453fc7735144 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.2.1"] + "requirements": ["apprise==1.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f857958a74f..ec177cc02e03 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -342,7 +342,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.2.1 +apprise==1.3.0 # homeassistant.components.aprs aprslib==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index be5f3bb7f39b..02005f89cc70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -311,7 +311,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.2.1 +apprise==1.3.0 # homeassistant.components.aprs aprslib==0.7.0 From 6112793b195af9ef4ac50bb7ecb3421c695eace4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 23 Feb 2023 16:26:17 +0100 Subject: [PATCH 0012/1058] Modernize Twentemilieu tests (#88640) --- tests/components/twentemilieu/conftest.py | 18 +- .../twentemilieu/snapshots/test_calendar.ambr | 103 +++++ .../snapshots/test_config_flow.ambr | 85 +++++ .../snapshots/test_diagnostics.ambr | 20 + .../twentemilieu/snapshots/test_sensor.ambr | 356 ++++++++++++++++++ .../components/twentemilieu/test_calendar.py | 64 +--- .../twentemilieu/test_config_flow.py | 42 +-- .../twentemilieu/test_diagnostics.py | 15 +- tests/components/twentemilieu/test_init.py | 8 +- tests/components/twentemilieu/test_sensor.py | 120 ++---- 10 files changed, 639 insertions(+), 192 deletions(-) create mode 100644 tests/components/twentemilieu/snapshots/test_calendar.ambr create mode 100644 tests/components/twentemilieu/snapshots/test_config_flow.ambr create mode 100644 tests/components/twentemilieu/snapshots/test_diagnostics.ambr create mode 100644 tests/components/twentemilieu/snapshots/test_sensor.ambr diff --git a/tests/components/twentemilieu/conftest.py b/tests/components/twentemilieu/conftest.py index 6e58ba2db48b..c42e3a9eb586 100644 --- a/tests/components/twentemilieu/conftest.py +++ b/tests/components/twentemilieu/conftest.py @@ -46,22 +46,14 @@ def mock_setup_entry() -> Generator[None, None, None]: @pytest.fixture -def mock_twentemilieu_config_flow() -> Generator[None, MagicMock, None]: - """Return a mocked Twente Milieu client.""" - with patch( - "homeassistant.components.twentemilieu.config_flow.TwenteMilieu", autospec=True - ) as twentemilieu_mock: - twentemilieu = twentemilieu_mock.return_value - twentemilieu.unique_id.return_value = 12345 - yield twentemilieu - - -@pytest.fixture -def mock_twentemilieu() -> Generator[None, MagicMock, None]: +def mock_twentemilieu() -> Generator[MagicMock, None, None]: """Return a mocked Twente Milieu client.""" with patch( "homeassistant.components.twentemilieu.TwenteMilieu", autospec=True - ) as twentemilieu_mock: + ) as twentemilieu_mock, patch( + "homeassistant.components.twentemilieu.config_flow.TwenteMilieu", + new=twentemilieu_mock, + ): twentemilieu = twentemilieu_mock.return_value twentemilieu.unique_id.return_value = 12345 twentemilieu.update.return_value = { diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr new file mode 100644 index 000000000000..04965b342bad --- /dev/null +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_api_calendar + list([ + dict({ + 'entity_id': 'calendar.twente_milieu', + 'name': 'Twente Milieu', + }), + ]) +# --- +# name: test_api_events + list([ + dict({ + 'description': None, + 'end': dict({ + 'date': '2022-01-06', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'date': '2022-01-06', + }), + 'summary': 'Christmas tree pickup', + 'uid': None, + }), + ]) +# --- +# name: test_waste_pickup_calendar + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'all_day': True, + 'description': '', + 'end_time': '2022-01-06 00:00:00', + 'friendly_name': 'Twente Milieu', + 'icon': 'mdi:delete-empty', + 'location': '', + 'message': 'Christmas tree pickup', + 'start_time': '2022-01-06 00:00:00', + }), + 'context': , + 'entity_id': 'calendar.twente_milieu', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_waste_pickup_calendar.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'calendar', + 'entity_category': None, + 'entity_id': 'calendar.twente_milieu', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:delete-empty', + 'original_name': None, + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345', + 'unit_of_measurement': None, + }) +# --- +# name: test_waste_pickup_calendar.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr new file mode 100644 index 000000000000..7acb466d9977 --- /dev/null +++ b/tests/components/twentemilieu/snapshots/test_config_flow.ambr @@ -0,0 +1,85 @@ +# serializer version: 1 +# name: test_full_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': '12345', + }), + 'data': dict({ + 'house_letter': 'A', + 'house_number': '1', + 'id': 12345, + 'post_code': '1234AB', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'twentemilieu', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'house_letter': 'A', + 'house_number': '1', + 'id': 12345, + 'post_code': '1234AB', + }), + 'disabled_by': None, + 'domain': 'twentemilieu', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '12345', + 'unique_id': '12345', + 'version': 1, + }), + 'title': '12345', + 'type': , + 'version': 1, + }) +# --- +# name: test_invalid_address + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': '12345', + }), + 'data': dict({ + 'house_letter': None, + 'house_number': '1', + 'id': 12345, + 'post_code': '1234AB', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'twentemilieu', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'house_letter': None, + 'house_number': '1', + 'id': 12345, + 'post_code': '1234AB', + }), + 'disabled_by': None, + 'domain': 'twentemilieu', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '12345', + 'unique_id': '12345', + 'version': 1, + }), + 'title': '12345', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/twentemilieu/snapshots/test_diagnostics.ambr b/tests/components/twentemilieu/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..d7c786fe5b33 --- /dev/null +++ b/tests/components/twentemilieu/snapshots/test_diagnostics.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'WasteType.NON_RECYCLABLE': list([ + '2021-11-01', + '2021-12-01', + ]), + 'WasteType.ORGANIC': list([ + '2021-11-02', + ]), + 'WasteType.PACKAGES': list([ + '2021-11-03', + ]), + 'WasteType.PAPER': list([ + ]), + 'WasteType.TREE': list([ + '2022-01-06', + ]), + }) +# --- diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..46b21ebab329 --- /dev/null +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -0,0 +1,356 @@ +# serializer version: 1 +# name: test_sensors[sensor.twente_milieu_christmas_tree_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Christmas tree pickup', + 'icon': 'mdi:pine-tree', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_christmas_tree_pickup', + 'last_changed': , + 'last_updated': , + 'state': '2022-01-06', + }) +# --- +# name: test_sensors[sensor.twente_milieu_christmas_tree_pickup].1 + 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.twente_milieu_christmas_tree_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pine-tree', + 'original_name': 'Christmas tree pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_tree', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_christmas_tree_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_non_recyclable_waste_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Non-recyclable waste pickup', + 'icon': 'mdi:delete-empty', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_non_recyclable_waste_pickup', + 'last_changed': , + 'last_updated': , + 'state': '2021-11-01', + }) +# --- +# name: test_sensors[sensor.twente_milieu_non_recyclable_waste_pickup].1 + 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.twente_milieu_non_recyclable_waste_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:delete-empty', + 'original_name': 'Non-recyclable waste pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_Non-recyclable', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_non_recyclable_waste_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_organic_waste_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Organic waste pickup', + 'icon': 'mdi:delete-empty', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_organic_waste_pickup', + 'last_changed': , + 'last_updated': , + 'state': '2021-11-02', + }) +# --- +# name: test_sensors[sensor.twente_milieu_organic_waste_pickup].1 + 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.twente_milieu_organic_waste_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:delete-empty', + 'original_name': 'Organic waste pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_Organic', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_organic_waste_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_packages_waste_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Packages waste pickup', + 'icon': 'mdi:delete-empty', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_packages_waste_pickup', + 'last_changed': , + 'last_updated': , + 'state': '2021-11-03', + }) +# --- +# name: test_sensors[sensor.twente_milieu_packages_waste_pickup].1 + 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.twente_milieu_packages_waste_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:delete-empty', + 'original_name': 'Packages waste pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_Plastic', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_packages_waste_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_paper_waste_pickup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Twente Milieu Paper waste pickup', + 'icon': 'mdi:delete-empty', + }), + 'context': , + 'entity_id': 'sensor.twente_milieu_paper_waste_pickup', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.twente_milieu_paper_waste_pickup].1 + 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.twente_milieu_paper_waste_pickup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:delete-empty', + 'original_name': 'Paper waste pickup', + 'platform': 'twentemilieu', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'twentemilieu_12345_Paper', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.twente_milieu_paper_waste_pickup].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'https://www.twentemilieu.nl', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'twentemilieu', + '12345', + ), + }), + 'is_new': False, + 'manufacturer': 'Twente Milieu', + 'model': None, + 'name': 'Twente Milieu', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/twentemilieu/test_calendar.py b/tests/components/twentemilieu/test_calendar.py index 2c6c2012766a..7610b8b003b6 100644 --- a/tests/components/twentemilieu/test_calendar.py +++ b/tests/components/twentemilieu/test_calendar.py @@ -2,71 +2,50 @@ from http import HTTPStatus import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.twentemilieu.const import DOMAIN -from homeassistant.const import ATTR_ICON, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator +pytestmark = pytest.mark.usefixtures("init_integration") + @pytest.mark.freeze_time("2022-01-05 00:00:00+00:00") async def test_waste_pickup_calendar( hass: HomeAssistant, - init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test the Twente Milieu waste pickup calendar.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) + assert (state := hass.states.get("calendar.twente_milieu")) + assert state == snapshot - state = hass.states.get("calendar.twente_milieu") - entry = entity_registry.async_get("calendar.twente_milieu") - assert entry - assert state - assert entry.unique_id == "12345" - assert state.attributes[ATTR_ICON] == "mdi:delete-empty" - assert state.attributes["all_day"] is True - assert state.attributes["message"] == "Christmas tree pickup" - assert not state.attributes["location"] - assert not state.attributes["description"] - assert state.state == STATE_OFF + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot - assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.identifiers == {(DOMAIN, "12345")} - assert device_entry.manufacturer == "Twente Milieu" - assert device_entry.name == "Twente Milieu" - assert device_entry.entry_type is dr.DeviceEntryType.SERVICE - assert device_entry.configuration_url == "https://www.twentemilieu.nl" - assert not device_entry.model - assert not device_entry.sw_version + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot async def test_api_calendar( - hass: HomeAssistant, - init_integration: MockConfigEntry, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the API returns the calendar.""" client = await hass_client() response = await client.get("/api/calendars") assert response.status == HTTPStatus.OK data = await response.json() - assert data == [ - { - "entity_id": "calendar.twente_milieu", - "name": "Twente Milieu", - } - ] + assert data == snapshot async def test_api_events( - hass: HomeAssistant, - init_integration: MockConfigEntry, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test the Twente Milieu calendar view.""" client = await hass_client() @@ -76,13 +55,4 @@ async def test_api_events( assert response.status == HTTPStatus.OK events = await response.json() assert len(events) == 1 - assert events[0] == { - "start": {"date": "2022-01-06"}, - "end": {"date": "2022-01-06"}, - "summary": "Christmas tree pickup", - "description": None, - "location": None, - "uid": None, - "recurrence_id": None, - "rrule": None, - } + assert events == snapshot diff --git a/tests/components/twentemilieu/test_config_flow.py b/tests/components/twentemilieu/test_config_flow.py index 83e7011b8819..d8c2c82f4eb8 100644 --- a/tests/components/twentemilieu/test_config_flow.py +++ b/tests/components/twentemilieu/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Twente Milieu config flow.""" from unittest.mock import MagicMock +import pytest +from syrupy.assertion import SnapshotAssertion from twentemilieu import TwenteMilieuAddressError, TwenteMilieuConnectionError from homeassistant import config_entries @@ -12,18 +14,16 @@ from homeassistant.components.twentemilieu.const import ( DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_full_user_flow( - hass: HomeAssistant, - mock_twentemilieu_config_flow: MagicMock, - mock_setup_entry: MagicMock, -) -> None: + +@pytest.mark.usefixtures("mock_twentemilieu") +async def test_full_user_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test registering an integration and finishing flow works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -42,19 +42,13 @@ async def test_full_user_flow( ) assert result2.get("type") == FlowResultType.CREATE_ENTRY - assert result2.get("title") == "12345" - assert result2.get("data") == { - CONF_ID: 12345, - CONF_POST_CODE: "1234AB", - CONF_HOUSE_NUMBER: "1", - CONF_HOUSE_LETTER: "A", - } + assert result2 == snapshot async def test_invalid_address( hass: HomeAssistant, - mock_twentemilieu_config_flow: MagicMock, - mock_setup_entry: MagicMock, + mock_twentemilieu: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test full user flow when the user enters an incorrect address. @@ -68,7 +62,7 @@ async def test_invalid_address( assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == SOURCE_USER - mock_twentemilieu_config_flow.unique_id.side_effect = TwenteMilieuAddressError + mock_twentemilieu.unique_id.side_effect = TwenteMilieuAddressError result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -81,7 +75,7 @@ async def test_invalid_address( assert result2.get("step_id") == SOURCE_USER assert result2.get("errors") == {"base": "invalid_address"} - mock_twentemilieu_config_flow.unique_id.side_effect = None + mock_twentemilieu.unique_id.side_effect = None result3 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -91,21 +85,15 @@ async def test_invalid_address( ) assert result3.get("type") == FlowResultType.CREATE_ENTRY - assert result3.get("title") == "12345" - assert result3.get("data") == { - CONF_ID: 12345, - CONF_POST_CODE: "1234AB", - CONF_HOUSE_NUMBER: "1", - CONF_HOUSE_LETTER: None, - } + assert result3 == snapshot async def test_connection_error( hass: HomeAssistant, - mock_twentemilieu_config_flow: MagicMock, + mock_twentemilieu: MagicMock, ) -> None: """Test we show user form on Twente Milieu connection error.""" - mock_twentemilieu_config_flow.unique_id.side_effect = TwenteMilieuConnectionError + mock_twentemilieu.unique_id.side_effect = TwenteMilieuConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -122,9 +110,9 @@ async def test_connection_error( assert result.get("errors") == {"base": "cannot_connect"} +@pytest.mark.usefixtures("mock_twentemilieu") async def test_address_already_set_up( hass: HomeAssistant, - mock_twentemilieu_config_flow: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test we abort if address has already been set up.""" diff --git a/tests/components/twentemilieu/test_diagnostics.py b/tests/components/twentemilieu/test_diagnostics.py index 36112d3d995e..0828d35ec51f 100644 --- a/tests/components/twentemilieu/test_diagnostics.py +++ b/tests/components/twentemilieu/test_diagnostics.py @@ -1,4 +1,5 @@ """Tests for the diagnostics data provided by the TwenteMilieu integration.""" +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -11,14 +12,10 @@ 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 - ) == { - "WasteType.NON_RECYCLABLE": ["2021-11-01", "2021-12-01"], - "WasteType.ORGANIC": ["2021-11-02"], - "WasteType.PAPER": [], - "WasteType.TREE": ["2022-01-06"], - "WasteType.PACKAGES": ["2021-11-03"], - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/twentemilieu/test_init.py b/tests/components/twentemilieu/test_init.py index d5fd108b67a7..b97578588bfe 100644 --- a/tests/components/twentemilieu/test_init.py +++ b/tests/components/twentemilieu/test_init.py @@ -1,5 +1,7 @@ """Tests for the Twente Milieu integration.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch + +import pytest from homeassistant.components.twentemilieu.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -8,10 +10,10 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_twentemilieu") async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_twentemilieu: AsyncMock, ) -> None: """Test the Twente Milieu configuration entry loading/unloading.""" mock_config_entry.add_to_hass(hass) @@ -45,10 +47,10 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("mock_twentemilieu") async def test_update_config_entry_unique_id( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_twentemilieu: AsyncMock, ) -> None: """Test the we update old config entries with an unique ID.""" mock_config_entry.unique_id = None diff --git a/tests/components/twentemilieu/test_sensor.py b/tests/components/twentemilieu/test_sensor.py index 6e20fd4d1411..6fd39e38d487 100644 --- a/tests/components/twentemilieu/test_sensor.py +++ b/tests/components/twentemilieu/test_sensor.py @@ -1,104 +1,38 @@ """Tests for the Twente Milieu sensors.""" -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.twentemilieu.const import DOMAIN -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNKNOWN, -) + +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("init_integration") -async def test_waste_pickup_sensors( +@pytest.mark.parametrize( + "entity_id", + [ + "sensor.twente_milieu_christmas_tree_pickup", + "sensor.twente_milieu_non_recyclable_waste_pickup", + "sensor.twente_milieu_organic_waste_pickup", + "sensor.twente_milieu_packages_waste_pickup", + "sensor.twente_milieu_paper_waste_pickup", + ], +) +async def test_sensors( hass: HomeAssistant, - init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, ) -> None: """Test the Twente Milieu waste pickup sensors.""" - entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) + assert (state := hass.states.get(entity_id)) + assert state == snapshot - state = hass.states.get("sensor.twente_milieu_christmas_tree_pickup") - entry = entity_registry.async_get("sensor.twente_milieu_christmas_tree_pickup") - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_tree" - assert state.state == "2022-01-06" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Twente Milieu Christmas tree pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:pine-tree" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot - state = hass.states.get("sensor.twente_milieu_non_recyclable_waste_pickup") - entry = entity_registry.async_get( - "sensor.twente_milieu_non_recyclable_waste_pickup" - ) - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_Non-recyclable" - assert state.state == "2021-11-01" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Twente Milieu Non-recyclable waste pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - - state = hass.states.get("sensor.twente_milieu_organic_waste_pickup") - entry = entity_registry.async_get("sensor.twente_milieu_organic_waste_pickup") - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_Organic" - assert state.state == "2021-11-02" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Twente Milieu Organic waste pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - - state = hass.states.get("sensor.twente_milieu_packages_waste_pickup") - entry = entity_registry.async_get("sensor.twente_milieu_packages_waste_pickup") - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_Plastic" - assert state.state == "2021-11-03" - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "Twente Milieu Packages waste pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - - state = hass.states.get("sensor.twente_milieu_paper_waste_pickup") - entry = entity_registry.async_get("sensor.twente_milieu_paper_waste_pickup") - assert entry - assert state - assert entry.unique_id == "twentemilieu_12345_Paper" - assert state.state == STATE_UNKNOWN - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Twente Milieu Paper waste pickup" - ) - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATE - assert state.attributes.get(ATTR_ICON) == "mdi:delete-empty" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - - assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry - assert device_entry.identifiers == {(DOMAIN, "12345")} - assert device_entry.manufacturer == "Twente Milieu" - assert device_entry.name == "Twente Milieu" - assert device_entry.entry_type is dr.DeviceEntryType.SERVICE - assert device_entry.configuration_url == "https://www.twentemilieu.nl" - assert not device_entry.model - assert not device_entry.sw_version + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot From 57397828773eb1c7f5dde0512e26ab283151885a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 23 Feb 2023 13:24:55 -0500 Subject: [PATCH 0013/1058] Add support for firmware target in zwave_js FirmwareUploadView (#88523) * Add support for firmware target in zwave_js FirmwareUploadView fix * Update tests/components/zwave_js/test_api.py Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/test_api.py Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/test_api.py Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/test_api.py Co-authored-by: Martin Hjelmare * fix types * Switch back to using Any --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 6 ++++- tests/components/zwave_js/test_api.py | 29 ++++++++++++++++++------ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 2612d2d4f68e..091de1949eb3 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import dataclasses from functools import partial, wraps -from typing import Any, Literal +from typing import Any, Literal, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -2186,6 +2186,9 @@ class FirmwareUploadView(HomeAssistantView): additional_user_agent_components=USER_AGENT, ) else: + firmware_target: int | None = None + if "target" in data: + firmware_target = int(cast(str, data["target"])) await update_firmware( node.client.ws_server_url, node, @@ -2193,6 +2196,7 @@ class FirmwareUploadView(HomeAssistantView): NodeFirmwareUpdateData( uploaded_file.filename, await hass.async_add_executor_job(uploaded_file.file.read), + firmware_target=firmware_target, ) ], async_get_clientsession(hass), diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index f988e72e70b0..4e99d19261b0 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2,6 +2,7 @@ from copy import deepcopy from http import HTTPStatus import json +from typing import Any from unittest.mock import patch import pytest @@ -2983,12 +2984,18 @@ async def test_get_config_parameters( assert msg["error"]["code"] == ERR_NOT_LOADED +@pytest.mark.parametrize( + ("firmware_data", "expected_data"), + [({"target": "1"}, {"firmware_target": 1}), ({}, {})], +) async def test_firmware_upload_view( hass: HomeAssistant, multisensor_6, integration, hass_client: ClientSessionGenerator, firmware_file, + firmware_data: dict[str, Any], + expected_data: dict[str, Any], ) -> None: """Test the HTTP firmware upload view.""" client = await hass_client() @@ -3001,15 +3008,19 @@ async def test_firmware_upload_view( "homeassistant.components.zwave_js.api.USER_AGENT", {"HomeAssistant": "0.0.0"}, ): + data = {"file": firmware_file} + data.update(firmware_data) + resp = await client.post( - f"/api/zwave_js/firmware/upload/{device.id}", - data={"file": firmware_file}, + f"/api/zwave_js/firmware/upload/{device.id}", data=data ) + + update_data = NodeFirmwareUpdateData("file", bytes(10)) + for attr, value in expected_data.items(): + setattr(update_data, attr, value) + mock_controller_cmd.assert_not_called() - assert mock_node_cmd.call_args[0][1:3] == ( - multisensor_6, - [NodeFirmwareUpdateData("file", bytes(10))], - ) + assert mock_node_cmd.call_args[0][1:3] == (multisensor_6, [update_data]) assert mock_node_cmd.call_args[1] == { "additional_user_agent_components": {"HomeAssistant": "0.0.0"}, } @@ -3017,7 +3028,11 @@ async def test_firmware_upload_view( async def test_firmware_upload_view_controller( - hass, client, integration, hass_client: ClientSessionGenerator, firmware_file + hass: HomeAssistant, + client, + integration, + hass_client: ClientSessionGenerator, + firmware_file, ) -> None: """Test the HTTP firmware upload view for a controller.""" hass_client = await hass_client() From e1e0400b162055bc361d381683ce774cceb9c857 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 23 Feb 2023 10:37:15 -0800 Subject: [PATCH 0014/1058] Fix local calendar issue with events created with fixed UTC offsets (#88650) Fix issue with events created with UTC offsets --- homeassistant/components/calendar/__init__.py | 85 ++++++++++++------- .../components/local_calendar/calendar.py | 25 +++++- tests/components/calendar/test_init.py | 27 ++++++ .../local_calendar/test_calendar.py | 40 ++++++++- 4 files changed, 141 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 390e14d16890..c77d6c9c67a3 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -66,6 +66,55 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60) # Don't support rrules more often than daily VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} + +def _has_consistent_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that all datetime values have a consistent timezone.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Test that all keys that are datetime values have the same timezone.""" + tzinfos = [] + for key in keys: + if not (value := obj.get(key)) or not isinstance(value, datetime.datetime): + return obj + tzinfos.append(value.tzinfo) + uniq_values = groupby(tzinfos) + if len(list(uniq_values)) > 1: + raise vol.Invalid("Expected all values to have the same timezone") + return obj + + return validate + + +def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Convert all datetime values to the local timezone.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Test that all keys that are datetime values have the same timezone.""" + for k in keys: + if (value := obj.get(k)) and isinstance(value, datetime.datetime): + obj[k] = dt.as_local(value) + return obj + + return validate + + +def _is_sorted(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that the specified values are sequential.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Test that all keys in the dict are in order.""" + values = [] + for k in keys: + if not (value := obj.get(k)): + return obj + values.append(value) + if all(values) and values != sorted(values): + raise vol.Invalid(f"Values were not in order: {values}") + return obj + + return validate + + CREATE_EVENT_SERVICE = "create_event" CREATE_EVENT_SCHEMA = vol.All( cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), @@ -98,6 +147,10 @@ CREATE_EVENT_SCHEMA = vol.All( ), }, ), + _has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), + _as_local_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), + _is_sorted(EVENT_START_DATE, EVENT_END_DATE), + _is_sorted(EVENT_START_DATETIME, EVENT_END_DATETIME), ) @@ -441,36 +494,6 @@ def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: return validate -def _has_consistent_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that all datetime values have a consistent timezone.""" - - def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys that are datetime values have the same timezone.""" - values = [obj[k] for k in keys] - if all(isinstance(value, datetime.datetime) for value in values): - uniq_values = groupby(value.tzinfo for value in values) - if len(list(uniq_values)) > 1: - raise vol.Invalid( - f"Expected all values to have the same timezone: {values}" - ) - return obj - - return validate - - -def _is_sorted(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that the specified values are sequential.""" - - def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys in the dict are in order.""" - values = [obj[k] for k in keys] - if values != sorted(values): - raise vol.Invalid(f"Values were not in order: {values}") - return obj - - return validate - - @websocket_api.websocket_command( { vol.Required("type"): "calendar/event/create", @@ -486,6 +509,7 @@ def _is_sorted(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: }, _has_same_type(EVENT_START, EVENT_END), _has_consistent_timezone(EVENT_START, EVENT_END), + _as_local_timezone(EVENT_START, EVENT_END), _is_sorted(EVENT_START, EVENT_END), ) ), @@ -582,6 +606,7 @@ async def handle_calendar_event_delete( }, _has_same_type(EVENT_START, EVENT_END), _has_consistent_timezone(EVENT_START, EVENT_END), + _as_local_timezone(EVENT_START, EVENT_END), _is_sorted(EVENT_START, EVENT_END), ) ), diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index be6fb4a17b56..88737150c02f 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -15,7 +15,9 @@ from pydantic import ValidationError import voluptuous as vol from homeassistant.components.calendar import ( + EVENT_END, EVENT_RRULE, + EVENT_START, CalendarEntity, CalendarEntityFeature, CalendarEvent, @@ -151,6 +153,21 @@ def _parse_event(event: dict[str, Any]) -> Event: """Parse an ical event from a home assistant event dictionary.""" if rrule := event.get(EVENT_RRULE): event[EVENT_RRULE] = Recur.from_rrule(rrule) + + # This function is called with new events created in the local timezone, + # however ical library does not properly return recurrence_ids for + # start dates with a timezone. For now, ensure any datetime is stored as a + # floating local time to ensure we still apply proper local timezone rules. + # This can be removed when ical is updated with a new recurrence_id format + # https://github.com/home-assistant/core/issues/87759 + for key in (EVENT_START, EVENT_END): + if ( + (value := event[key]) + and isinstance(value, datetime) + and value.tzinfo is not None + ): + event[key] = dt_util.as_local(value).replace(tzinfo=None) + try: return Event.parse_obj(event) except ValidationError as err: @@ -162,8 +179,12 @@ def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" return CalendarEvent( summary=event.summary, - start=event.start, - end=event.end, + start=dt_util.as_local(event.start) + if isinstance(event.start, datetime) + else event.start, + end=dt_util.as_local(event.end) + if isinstance(event.end, datetime) + else event.end, description=event.description, uid=event.uid, rrule=event.rrule.as_rrule_str() if event.rrule else None, diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 806410c9834a..5c90a1cfc2c6 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -310,6 +310,30 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: vol.error.MultipleInvalid, "must contain at most one of start_date, start_date_time, in.", ), + ( + { + "start_date_time": "2022-04-01T06:00:00+00:00", + "end_date_time": "2022-04-01T07:00:00+01:00", + }, + vol.error.MultipleInvalid, + "Expected all values to have the same timezone", + ), + ( + { + "start_date_time": "2022-04-01T07:00:00", + "end_date_time": "2022-04-01T06:00:00", + }, + vol.error.MultipleInvalid, + "Values were not in order", + ), + ( + { + "start_date": "2022-04-02", + "end_date": "2022-04-01", + }, + vol.error.MultipleInvalid, + "Values were not in order", + ), ], ids=[ "missing_all", @@ -324,6 +348,9 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: "multiple_in", "unexpected_in_with_date", "unexpected_in_with_datetime", + "inconsistent_timezone", + "incorrect_date_order", + "incorrect_datetime_order", ], ) async def test_create_event_service_invalid_params( diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index c7eea20920fc..f432fe3f9771 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -48,8 +48,12 @@ class FakeStore(LocalCalendarStore): def mock_store() -> None: """Test cleanup, remove any media storage persisted during the test.""" + stores: dict[Path, FakeStore] = {} + def new_store(hass: HomeAssistant, path: Path) -> FakeStore: - return FakeStore(hass, path) + if path not in stores: + stores[path] = FakeStore(hass, path) + return stores[path] with patch( "homeassistant.components.local_calendar.LocalCalendarStore", new=new_store @@ -961,8 +965,20 @@ async def test_update_invalid_event_id( assert resp.get("error").get("code") == "failed" +@pytest.mark.parametrize( + ("start_date_time", "end_date_time"), + [ + ("1997-07-14T17:00:00+00:00", "1997-07-15T04:00:00+00:00"), + ("1997-07-14T11:00:00-06:00", "1997-07-14T22:00:00-06:00"), + ], +) async def test_create_event_service( - hass: HomeAssistant, setup_integration: None, get_events: GetEventsFn + hass: HomeAssistant, + setup_integration: None, + get_events: GetEventsFn, + start_date_time: str, + end_date_time: str, + config_entry: MockConfigEntry, ) -> None: """Test creating an event using the create_event service.""" @@ -970,13 +986,15 @@ async def test_create_event_service( "calendar", "create_event", { - "start_date_time": "1997-07-14T17:00:00+00:00", - "end_date_time": "1997-07-15T04:00:00+00:00", + "start_date_time": start_date_time, + "end_date_time": end_date_time, "summary": "Bastille Day Party", }, target={"entity_id": TEST_ENTITY}, blocking=True, ) + # Ensure data is written to disk + await hass.async_block_till_done() events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z") assert list(map(event_fields, events)) == [ @@ -995,3 +1013,17 @@ async def test_create_event_service( "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, } ] + + # Reload the config entry, which reloads the content from the store and + # verifies that the persisted data can be parsed correctly. + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T18:00:00Z") + assert list(map(event_fields, events)) == [ + { + "summary": "Bastille Day Party", + "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, + "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + } + ] From e0601530a01b26021f5478694a6b0fd88f01b1a2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 23 Feb 2023 19:38:07 +0100 Subject: [PATCH 0015/1058] Update frontend to 20230223.0 (#88677) --- 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 17cbbb72efe2..a5930177b9cd 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==20230222.0"] + "requirements": ["home-assistant-frontend==20230223.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5193492a0752..4675a2ae9237 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.5 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230222.0 +home-assistant-frontend==20230223.0 home-assistant-intents==2023.2.22 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index ec177cc02e03..1befa3fee917 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230222.0 +home-assistant-frontend==20230223.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02005f89cc70..b643171a7f63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230222.0 +home-assistant-frontend==20230223.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 From 301144993cb39d9dc8c4fd752d7d50c3fc9f218d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 23 Feb 2023 20:58:37 +0100 Subject: [PATCH 0016/1058] Fix support for Bridge(d) and composed devices in Matter (#88662) * Refactor discovery of entities to support composed and bridged devices * Bump library version to 3.1.0 * move discovery schemas to platforms * optimize a tiny bit * simplify even more * fixed bug in light platform * fix color control logic * fix some issues * Update homeassistant/components/matter/discovery.py Co-authored-by: Paulus Schoutsen * fix some tests * fix light test --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/matter/__init__.py | 10 +- homeassistant/components/matter/adapter.py | 122 +++----- .../components/matter/binary_sensor.py | 117 ++++---- .../components/matter/device_platform.py | 30 -- homeassistant/components/matter/discovery.py | 115 ++++++++ homeassistant/components/matter/entity.py | 60 ++-- homeassistant/components/matter/helpers.py | 29 +- homeassistant/components/matter/light.py | 273 +++++++----------- homeassistant/components/matter/manifest.json | 2 +- homeassistant/components/matter/models.py | 109 +++++++ homeassistant/components/matter/sensor.py | 164 +++++------ homeassistant/components/matter/switch.py | 53 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/matter/test_binary_sensor.py | 4 +- tests/components/matter/test_helpers.py | 2 +- tests/components/matter/test_light.py | 10 +- tests/components/matter/test_sensor.py | 4 +- 18 files changed, 582 insertions(+), 526 deletions(-) delete mode 100644 homeassistant/components/matter/device_platform.py create mode 100644 homeassistant/components/matter/discovery.py create mode 100644 homeassistant/components/matter/models.py diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 111e7c0ea962..e86e5c0ca490 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -27,7 +27,7 @@ from .adapter import MatterAdapter from .addon import get_addon_manager from .api import async_register_api from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER -from .device_platform import DEVICE_PLATFORM +from .discovery import SUPPORTED_PLATFORMS from .helpers import MatterEntryData, get_matter, get_node_from_device_entry CONNECT_TIMEOUT = 10 @@ -101,12 +101,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: matter = MatterAdapter(hass, matter_client, entry) hass.data[DOMAIN][entry.entry_id] = MatterEntryData(matter, listen_task) - await hass.config_entries.async_forward_entry_setups(entry, DEVICE_PLATFORM) + await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_PLATFORMS) await matter.setup_nodes() # If the listen task is already failed, we need to raise ConfigEntryNotReady if listen_task.done() and (listen_error := listen_task.exception()) is not None: - await hass.config_entries.async_unload_platforms(entry, DEVICE_PLATFORM) + await hass.config_entries.async_unload_platforms(entry, SUPPORTED_PLATFORMS) hass.data[DOMAIN].pop(entry.entry_id) try: await matter_client.disconnect() @@ -142,7 +142,9 @@ async def _client_listen( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, DEVICE_PLATFORM) + unload_ok = await hass.config_entries.async_unload_platforms( + entry, SUPPORTED_PLATFORMS + ) if unload_ok: matter_entry_data: MatterEntryData = hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 5bcec4b433d9..fbc027091b4d 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -3,11 +3,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast -from chip.clusters import Objects as all_clusters -from matter_server.client.models.node_device import ( - AbstractMatterNodeDevice, - MatterBridgedNodeDevice, -) from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.config_entries import ConfigEntry @@ -17,12 +12,12 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ID_TYPE_DEVICE_ID, ID_TYPE_SERIAL, LOGGER -from .device_platform import DEVICE_PLATFORM +from .discovery import async_discover_entities from .helpers import get_device_id if TYPE_CHECKING: from matter_server.client import MatterClient - from matter_server.client.models.node import MatterNode + from matter_server.client.models.node import MatterEndpoint, MatterNode class MatterAdapter: @@ -51,12 +46,8 @@ class MatterAdapter: for node in await self.matter_client.get_nodes(): self._setup_node(node) - def node_added_callback(event: EventType, node: MatterNode | None) -> None: + def node_added_callback(event: EventType, node: MatterNode) -> None: """Handle node added event.""" - if node is None: - # We can clean this up when we've improved the typing in the library. - # https://github.com/home-assistant-libs/python-matter-server/pull/153 - raise RuntimeError("Node added event without node") self._setup_node(node) self.config_entry.async_on_unload( @@ -67,48 +58,32 @@ class MatterAdapter: """Set up an node.""" LOGGER.debug("Setting up entities for node %s", node.node_id) - bridge_unique_id: str | None = None - - if ( - node.aggregator_device_type_instance is not None - and node.root_device_type_instance is not None - and node.root_device_type_instance.get_cluster( - all_clusters.BasicInformation - ) - ): - # create virtual (parent) device for bridge node device - bridge_device = MatterBridgedNodeDevice( - node.aggregator_device_type_instance - ) - self._create_device_registry(bridge_device) - server_info = cast(ServerInfoMessage, self.matter_client.server_info) - bridge_unique_id = get_device_id(server_info, bridge_device) - - for node_device in node.node_devices: - self._setup_node_device(node_device, bridge_unique_id) + for endpoint in node.endpoints.values(): + # Node endpoints are translated into HA devices + self._setup_endpoint(endpoint) def _create_device_registry( self, - node_device: AbstractMatterNodeDevice, - bridge_unique_id: str | None = None, + endpoint: MatterEndpoint, ) -> None: - """Create a device registry entry.""" + """Create a device registry entry for a MatterNode.""" server_info = cast(ServerInfoMessage, self.matter_client.server_info) - basic_info = node_device.device_info() - device_type_instances = node_device.device_type_instances() + basic_info = endpoint.device_info + name = basic_info.nodeLabel or basic_info.productLabel or basic_info.productName - name = basic_info.nodeLabel - if not name and isinstance(node_device, MatterBridgedNodeDevice): - # fallback name for Bridge - name = "Hub device" - elif not name and device_type_instances: - # use the productName if no node label is present - name = basic_info.productName + # handle bridged devices + bridge_device_id = None + if endpoint.is_bridged_device: + bridge_device_id = get_device_id( + server_info, + endpoint.node.endpoints[0], + ) + bridge_device_id = f"{ID_TYPE_DEVICE_ID}_{bridge_device_id}" node_device_id = get_device_id( server_info, - node_device, + endpoint, ) identifiers = {(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} # if available, we also add the serialnumber as identifier @@ -124,50 +99,21 @@ class MatterAdapter: sw_version=basic_info.softwareVersionString, manufacturer=basic_info.vendorName, model=basic_info.productName, - via_device=(DOMAIN, bridge_unique_id) if bridge_unique_id else None, + via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None, ) - def _setup_node_device( - self, node_device: AbstractMatterNodeDevice, bridge_unique_id: str | None - ) -> None: - """Set up a node device.""" - self._create_device_registry(node_device, bridge_unique_id) + def _setup_endpoint(self, endpoint: MatterEndpoint) -> None: + """Set up a MatterEndpoint as HA Device.""" + # pre-create device registry entry + self._create_device_registry(endpoint) # run platform discovery from device type instances - for instance in node_device.device_type_instances(): - created = False - - for platform, devices in DEVICE_PLATFORM.items(): - entity_descriptions = devices.get(instance.device_type) - - if entity_descriptions is None: - continue - - if not isinstance(entity_descriptions, list): - entity_descriptions = [entity_descriptions] - - entities = [] - for entity_description in entity_descriptions: - LOGGER.debug( - "Creating %s entity for %s (%s)", - platform, - instance.device_type.__name__, - hex(instance.device_type.device_type), - ) - entities.append( - entity_description.entity_cls( - self.matter_client, - node_device, - instance, - entity_description, - ) - ) - - self.platform_handlers[platform](entities) - created = True - - if not created: - LOGGER.warning( - "Found unsupported device %s (%s)", - type(instance).__name__, - hex(instance.device_type.device_type), - ) + for entity_info in async_discover_entities(endpoint): + LOGGER.debug( + "Creating %s entity for %s", + entity_info.platform, + entity_info.primary_attribute, + ) + new_entity = entity_info.entity_class( + self.matter_client, endpoint, entity_info + ) + self.platform_handlers[entity_info.platform]([new_entity]) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index ce5b7a109168..b4d1b867e77e 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -1,11 +1,9 @@ """Matter binary sensors.""" from __future__ import annotations -from dataclasses import dataclass -from functools import partial - from chip.clusters import Objects as clusters -from matter_server.client.models import device_types +from chip.clusters.Objects import uint +from chip.clusters.Types import Nullable, NullValue from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -17,8 +15,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity, MatterEntityDescriptionBaseClass +from .entity import MatterEntity from .helpers import get_matter +from .models import MatterDiscoverySchema async def async_setup_entry( @@ -34,60 +33,70 @@ async def async_setup_entry( class MatterBinarySensor(MatterEntity, BinarySensorEntity): """Representation of a Matter binary sensor.""" - entity_description: MatterBinarySensorEntityDescription - @callback def _update_from_device(self) -> None: """Update from device.""" - self._attr_is_on = self.get_matter_attribute_value( - # We always subscribe to a single value - self.entity_description.subscribe_attributes[0], - ) + value: bool | uint | int | Nullable | None + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value in (None, NullValue): + value = None + elif value_convert := self._entity_info.measurement_to_ha: + value = value_convert(value) + self._attr_is_on = value -class MatterOccupancySensor(MatterBinarySensor): - """Representation of a Matter occupancy sensor.""" - - _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - - @callback - def _update_from_device(self) -> None: - """Update from device.""" - value = self.get_matter_attribute_value( - # We always subscribe to a single value - self.entity_description.subscribe_attributes[0], - ) +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + # device specific: translate Hue motion to sensor to HA Motion sensor + # instead of generic occupancy sensor + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=BinarySensorEntityDescription( + key="HueMotionSensor", + device_class=BinarySensorDeviceClass.MOTION, + name="Motion", + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), + vendor_id=(4107,), + product_name=("Hue motion sensor",), + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=BinarySensorEntityDescription( + key="ContactSensor", + device_class=BinarySensorDeviceClass.DOOR, + name="Contact", + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.BooleanState.Attributes.StateValue,), + # value is inverted on matter to what we expect + measurement_to_ha=lambda x: not x, + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=BinarySensorEntityDescription( + key="OccupancySensor", + device_class=BinarySensorDeviceClass.OCCUPANCY, + name="Occupancy", + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), # The first bit = if occupied - self._attr_is_on = (value & 1 == 1) if value is not None else None - - -@dataclass -class MatterBinarySensorEntityDescription( - BinarySensorEntityDescription, - MatterEntityDescriptionBaseClass, -): - """Matter Binary Sensor entity description.""" - - -# You can't set default values on inherited data classes -MatterSensorEntityDescriptionFactory = partial( - MatterBinarySensorEntityDescription, entity_cls=MatterBinarySensor -) - -DEVICE_ENTITY: dict[ - type[device_types.DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], -] = { - device_types.ContactSensor: MatterSensorEntityDescriptionFactory( - key=device_types.ContactSensor, - name="Contact", - subscribe_attributes=(clusters.BooleanState.Attributes.StateValue,), - device_class=BinarySensorDeviceClass.DOOR, + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), - device_types.OccupancySensor: MatterSensorEntityDescriptionFactory( - key=device_types.OccupancySensor, - name="Occupancy", - entity_cls=MatterOccupancySensor, - subscribe_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=BinarySensorEntityDescription( + key="BatteryChargeLevel", + device_class=BinarySensorDeviceClass.BATTERY, + name="Battery Status", + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.PowerSource.Attributes.BatChargeLevel,), + # only add binary battery sensor if a regular percentage based is not available + absent_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), + measurement_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), -} +] diff --git a/homeassistant/components/matter/device_platform.py b/homeassistant/components/matter/device_platform.py deleted file mode 100644 index 35b5d40b6dad..000000000000 --- a/homeassistant/components/matter/device_platform.py +++ /dev/null @@ -1,30 +0,0 @@ -"""All mappings of Matter devices to Home Assistant platforms.""" -from __future__ import annotations - -from typing import TYPE_CHECKING - -from homeassistant.const import Platform - -from .binary_sensor import DEVICE_ENTITY as BINARY_SENSOR_DEVICE_ENTITY -from .light import DEVICE_ENTITY as LIGHT_DEVICE_ENTITY -from .sensor import DEVICE_ENTITY as SENSOR_DEVICE_ENTITY -from .switch import DEVICE_ENTITY as SWITCH_DEVICE_ENTITY - -if TYPE_CHECKING: - from matter_server.client.models.device_types import DeviceType - - from .entity import MatterEntityDescriptionBaseClass - - -DEVICE_PLATFORM: dict[ - Platform, - dict[ - type[DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], - ], -] = { - Platform.BINARY_SENSOR: BINARY_SENSOR_DEVICE_ENTITY, - Platform.LIGHT: LIGHT_DEVICE_ENTITY, - Platform.SENSOR: SENSOR_DEVICE_ENTITY, - Platform.SWITCH: SWITCH_DEVICE_ENTITY, -} diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py new file mode 100644 index 000000000000..3fb8481dc94d --- /dev/null +++ b/homeassistant/components/matter/discovery.py @@ -0,0 +1,115 @@ +"""Map Matter Nodes and Attributes to Home Assistant entities.""" +from __future__ import annotations + +from collections.abc import Generator + +from chip.clusters.Objects import ClusterAttributeDescriptor +from matter_server.client.models.node import MatterEndpoint + +from homeassistant.const import Platform +from homeassistant.core import callback + +from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS +from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS +from .models import MatterDiscoverySchema, MatterEntityInfo +from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS +from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS + +DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { + Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, + Platform.LIGHT: LIGHT_SCHEMAS, + Platform.SENSOR: SENSOR_SCHEMAS, + Platform.SWITCH: SWITCH_SCHEMAS, +} +SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS.keys()) + + +@callback +def iter_schemas() -> Generator[MatterDiscoverySchema, None, None]: + """Iterate over all available discovery schemas.""" + for platform_schemas in DISCOVERY_SCHEMAS.values(): + yield from platform_schemas + + +@callback +def async_discover_entities( + endpoint: MatterEndpoint, +) -> Generator[MatterEntityInfo, None, None]: + """Run discovery on MatterEndpoint and return matching MatterEntityInfo(s).""" + discovered_attributes: set[type[ClusterAttributeDescriptor]] = set() + device_info = endpoint.device_info + for schema in iter_schemas(): + # abort if attribute(s) already discovered + if any(x in schema.required_attributes for x in discovered_attributes): + continue + + # check vendor_id + if ( + schema.vendor_id is not None + and device_info.vendorID not in schema.vendor_id + ): + continue + + # check product_name + if ( + schema.product_name is not None + and device_info.productName not in schema.product_name + ): + continue + + # check required device_type + if schema.device_type is not None and not any( + x in schema.device_type for x in endpoint.device_types + ): + continue + + # check absent device_type + if schema.not_device_type is not None and any( + x in schema.not_device_type for x in endpoint.device_types + ): + continue + + # check endpoint_id + if ( + schema.endpoint_id is not None + and endpoint.endpoint_id not in schema.endpoint_id + ): + continue + + # check required attributes + if schema.required_attributes is not None and not all( + endpoint.has_attribute(None, val_schema) + for val_schema in schema.required_attributes + ): + continue + + # check for values that may not be present + if schema.absent_attributes is not None and any( + endpoint.has_attribute(None, val_schema) + for val_schema in schema.absent_attributes + ): + continue + + # all checks passed, this value belongs to an entity + + attributes_to_watch = list(schema.required_attributes) + if schema.optional_attributes: + # check optional attributes + for optional_attribute in schema.optional_attributes: + if optional_attribute in attributes_to_watch: + continue + if endpoint.has_attribute(None, optional_attribute): + attributes_to_watch.append(optional_attribute) + + yield MatterEntityInfo( + endpoint=endpoint, + platform=schema.platform, + attributes_to_watch=attributes_to_watch, + entity_description=schema.entity_description, + entity_class=schema.entity_class, + measurement_to_ha=schema.measurement_to_ha, + ) + + # prevent re-discovery of the same attributes + if not schema.allow_multi: + discovered_attributes.update(attributes_to_watch) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 4a0c8f6a6037..a1d67158ab05 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -3,90 +3,77 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable -from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast from chip.clusters.Objects import ClusterAttributeDescriptor -from matter_server.client.models.device_type_instance import MatterDeviceTypeInstance -from matter_server.client.models.node_device import AbstractMatterNodeDevice from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN, ID_TYPE_DEVICE_ID -from .helpers import get_device_id, get_operational_instance_id +from .helpers import get_device_id if TYPE_CHECKING: from matter_server.client import MatterClient + from matter_server.client.models.node import MatterEndpoint + + from .discovery import MatterEntityInfo LOGGER = logging.getLogger(__name__) -@dataclass -class MatterEntityDescription: - """Mixin to map a matter device to a Home Assistant entity.""" - - entity_cls: type[MatterEntity] - subscribe_attributes: tuple - - -@dataclass -class MatterEntityDescriptionBaseClass(EntityDescription, MatterEntityDescription): - """For typing a base class that inherits from both entity descriptions.""" - - class MatterEntity(Entity): """Entity class for Matter devices.""" - entity_description: MatterEntityDescriptionBaseClass _attr_should_poll = False _attr_has_entity_name = True def __init__( self, matter_client: MatterClient, - node_device: AbstractMatterNodeDevice, - device_type_instance: MatterDeviceTypeInstance, - entity_description: MatterEntityDescriptionBaseClass, + endpoint: MatterEndpoint, + entity_info: MatterEntityInfo, ) -> None: """Initialize the entity.""" self.matter_client = matter_client - self._node_device = node_device - self._device_type_instance = device_type_instance - self.entity_description = entity_description + self._endpoint = endpoint + self._entity_info = entity_info + self.entity_description = entity_info.entity_description self._unsubscribes: list[Callable] = [] # for fast lookups we create a mapping to the attribute paths self._attributes_map: dict[type, str] = {} # The server info is set when the client connects to the server. server_info = cast(ServerInfoMessage, self.matter_client.server_info) # create unique_id based on "Operational Instance Name" and endpoint/device type + node_device_id = get_device_id(server_info, endpoint) self._attr_unique_id = ( - f"{get_operational_instance_id(server_info, self._node_device.node())}-" - f"{device_type_instance.endpoint.endpoint_id}-" - f"{device_type_instance.device_type.device_type}" + f"{node_device_id}-" + f"{endpoint.endpoint_id}-" + f"{entity_info.entity_description.key}-" + f"{entity_info.primary_attribute.cluster_id}-" + f"{entity_info.primary_attribute.attribute_id}" ) - node_device_id = get_device_id(server_info, node_device) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")} ) - self._attr_available = self._node_device.node().available + self._attr_available = self._endpoint.node.available async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" await super().async_added_to_hass() # Subscribe to attribute updates. - for attr_cls in self.entity_description.subscribe_attributes: + for attr_cls in self._entity_info.attributes_to_watch: attr_path = self.get_matter_attribute_path(attr_cls) self._attributes_map[attr_cls] = attr_path self._unsubscribes.append( self.matter_client.subscribe( callback=self._on_matter_event, event_filter=EventType.ATTRIBUTE_UPDATED, - node_filter=self._device_type_instance.node.node_id, + node_filter=self._endpoint.node.node_id, attr_path_filter=attr_path, ) ) @@ -95,7 +82,7 @@ class MatterEntity(Entity): self.matter_client.subscribe( callback=self._on_matter_event, event_filter=EventType.NODE_UPDATED, - node_filter=self._device_type_instance.node.node_id, + node_filter=self._endpoint.node.node_id, ) ) @@ -110,7 +97,7 @@ class MatterEntity(Entity): @callback def _on_matter_event(self, event: EventType, data: Any = None) -> None: """Call on update.""" - self._attr_available = self._device_type_instance.node.available + self._attr_available = self._endpoint.node.available self._update_from_device() self.async_write_ha_state() @@ -124,14 +111,13 @@ class MatterEntity(Entity): self, attribute: type[ClusterAttributeDescriptor] ) -> Any: """Get current value for given attribute.""" - return self._device_type_instance.get_attribute_value(None, attribute) + return self._endpoint.get_attribute_value(None, attribute) @callback def get_matter_attribute_path( self, attribute: type[ClusterAttributeDescriptor] ) -> str: """Return AttributePath by providing the endpoint and Attribute class.""" - endpoint = self._device_type_instance.endpoint.endpoint_id return create_attribute_path( - endpoint, attribute.cluster_id, attribute.attribute_id + self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id ) diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index 994ab0ff80cd..4b6099502562 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -11,8 +11,7 @@ from homeassistant.helpers import device_registry as dr from .const import DOMAIN, ID_TYPE_DEVICE_ID if TYPE_CHECKING: - from matter_server.client.models.node import MatterNode - from matter_server.client.models.node_device import AbstractMatterNodeDevice + from matter_server.client.models.node import MatterEndpoint, MatterNode from matter_server.common.models import ServerInfoMessage from .adapter import MatterAdapter @@ -50,15 +49,21 @@ def get_operational_instance_id( def get_device_id( server_info: ServerInfoMessage, - node_device: AbstractMatterNodeDevice, + endpoint: MatterEndpoint, ) -> str: - """Return HA device_id for the given MatterNodeDevice.""" - operational_instance_id = get_operational_instance_id( - server_info, node_device.node() - ) - # Append nodedevice(type) to differentiate between a root node - # and bridge within Home Assistant devices. - return f"{operational_instance_id}-{node_device.__class__.__name__}" + """Return HA device_id for the given MatterEndpoint.""" + operational_instance_id = get_operational_instance_id(server_info, endpoint.node) + # Append endpoint ID if this endpoint is a bridged or composed device + if endpoint.is_composed_device: + compose_parent = endpoint.node.get_compose_parent(endpoint.endpoint_id) + assert compose_parent is not None + postfix = str(compose_parent.endpoint_id) + elif endpoint.is_bridged_device: + postfix = str(endpoint.endpoint_id) + else: + # this should be compatible with previous versions + postfix = "MatterNodeDevice" + return f"{operational_instance_id}-{postfix}" async def get_node_from_device_entry( @@ -91,8 +96,8 @@ async def get_node_from_device_entry( ( node for node in await matter_client.get_nodes() - for node_device in node.node_devices - if get_device_id(server_info, node_device) == device_id + for endpoint in node.endpoints.values() + if get_device_id(server_info, endpoint) == device_id ), None, ) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index a891870bbef2..da0739cd4179 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -1,9 +1,6 @@ """Matter light.""" from __future__ import annotations -from dataclasses import dataclass -from enum import Enum -from functools import partial from typing import Any from chip.clusters import Objects as clusters @@ -24,8 +21,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import LOGGER -from .entity import MatterEntity, MatterEntityDescriptionBaseClass +from .entity import MatterEntity from .helpers import get_matter +from .models import MatterDiscoverySchema from .util import ( convert_to_hass_hs, convert_to_hass_xy, @@ -34,32 +32,13 @@ from .util import ( renormalize, ) - -class MatterColorMode(Enum): - """Matter color mode.""" - - HS = 0 - XY = 1 - COLOR_TEMP = 2 - - COLOR_MODE_MAP = { - MatterColorMode.HS: ColorMode.HS, - MatterColorMode.XY: ColorMode.XY, - MatterColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP, + clusters.ColorControl.Enums.ColorMode.kCurrentHueAndCurrentSaturation: ColorMode.HS, + clusters.ColorControl.Enums.ColorMode.kCurrentXAndCurrentY: ColorMode.XY, + clusters.ColorControl.Enums.ColorMode.kColorTemperature: ColorMode.COLOR_TEMP, } -class MatterColorControlFeatures(Enum): - """Matter color control features.""" - - HS = 0 # Hue and saturation (Optional if device is color capable) - EHUE = 1 # Enhanced hue and saturation (Optional if device is color capable) - COLOR_LOOP = 2 # Color loop (Optional if device is color capable) - XY = 3 # XY (Mandatory if device is color capable) - COLOR_TEMP = 4 # Color temperature (Mandatory if device is color capable) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -73,63 +52,37 @@ async def async_setup_entry( class MatterLight(MatterEntity, LightEntity): """Representation of a Matter light.""" - entity_description: MatterLightEntityDescription - - def _supports_feature( - self, feature_map: int, feature: MatterColorControlFeatures - ) -> bool: - """Return if device supports given feature.""" - - return (feature_map & (1 << feature.value)) != 0 - - def _supports_color_mode(self, color_feature: MatterColorControlFeatures) -> bool: - """Return if device supports given color mode.""" - - feature_map = self.get_matter_attribute_value( - clusters.ColorControl.Attributes.FeatureMap, - ) - - assert isinstance(feature_map, int) - - return self._supports_feature(feature_map, color_feature) - - def _supports_hs_color(self) -> bool: - """Return if device supports hs color.""" - - return self._supports_color_mode(MatterColorControlFeatures.HS) - - def _supports_xy_color(self) -> bool: - """Return if device supports xy color.""" - - return self._supports_color_mode(MatterColorControlFeatures.XY) - - def _supports_color_temperature(self) -> bool: - """Return if device supports color temperature.""" - - return self._supports_color_mode(MatterColorControlFeatures.COLOR_TEMP) - - def _supports_brightness(self) -> bool: - """Return if device supports brightness.""" + 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 ( - clusters.LevelControl.Attributes.CurrentLevel - in self.entity_description.subscribe_attributes + ColorMode.HS in self._attr_supported_color_modes + or ColorMode.XY in self._attr_supported_color_modes ) - def _supports_color(self) -> bool: - """Return if device supports color.""" + @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 - return ( - clusters.ColorControl.Attributes.ColorMode - in self.entity_description.subscribe_attributes - ) + @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 async def _set_xy_color(self, xy_color: tuple[float, float]) -> None: """Set xy color.""" matter_xy = convert_to_matter_xy(xy_color) - LOGGER.debug("Setting xy color to %s", matter_xy) await self.send_device_command( clusters.ColorControl.Commands.MoveToColor( colorX=int(matter_xy[0]), @@ -144,7 +97,6 @@ class MatterLight(MatterEntity, LightEntity): matter_hs = convert_to_matter_hs(hs_color) - LOGGER.debug("Setting hs color to %s", matter_hs) await self.send_device_command( clusters.ColorControl.Commands.MoveToHueAndSaturation( hue=int(matter_hs[0]), @@ -157,7 +109,6 @@ class MatterLight(MatterEntity, LightEntity): async def _set_color_temp(self, color_temp: int) -> None: """Set color temperature.""" - LOGGER.debug("Setting color temperature to %s", color_temp) await self.send_device_command( clusters.ColorControl.Commands.MoveToColorTemperature( colorTemperature=color_temp, @@ -169,8 +120,7 @@ class MatterLight(MatterEntity, LightEntity): async def _set_brightness(self, brightness: int) -> None: """Set brightness.""" - LOGGER.debug("Setting brightness to %s", brightness) - level_control = self._device_type_instance.get_cluster(clusters.LevelControl) + level_control = self._endpoint.get_cluster(clusters.LevelControl) assert level_control is not None @@ -207,7 +157,7 @@ class MatterLight(MatterEntity, LightEntity): LOGGER.debug( "Got xy color %s for %s", xy_color, - self._device_type_instance, + self.entity_id, ) return xy_color @@ -231,7 +181,7 @@ class MatterLight(MatterEntity, LightEntity): LOGGER.debug( "Got hs color %s for %s", hs_color, - self._device_type_instance, + self.entity_id, ) return hs_color @@ -248,7 +198,7 @@ class MatterLight(MatterEntity, LightEntity): LOGGER.debug( "Got color temperature %s for %s", color_temp, - self._device_type_instance, + self.entity_id, ) return int(color_temp) @@ -256,7 +206,7 @@ class MatterLight(MatterEntity, LightEntity): def _get_brightness(self) -> int: """Get brightness from matter.""" - level_control = self._device_type_instance.get_cluster(clusters.LevelControl) + level_control = self._endpoint.get_cluster(clusters.LevelControl) # We should not get here if brightness is not supported. assert level_control is not None @@ -264,7 +214,7 @@ class MatterLight(MatterEntity, LightEntity): LOGGER.debug( # type: ignore[unreachable] "Got brightness %s for %s", level_control.currentLevel, - self._device_type_instance, + self.entity_id, ) return round( @@ -284,10 +234,12 @@ class MatterLight(MatterEntity, LightEntity): assert color_mode is not None - ha_color_mode = COLOR_MODE_MAP[MatterColorMode(color_mode)] + ha_color_mode = COLOR_MODE_MAP[color_mode] LOGGER.debug( - "Got color mode (%s) for %s", ha_color_mode, self._device_type_instance + "Got color mode (%s) for %s", + ha_color_mode, + self.entity_id, ) return ha_color_mode @@ -295,8 +247,8 @@ class MatterLight(MatterEntity, LightEntity): async def send_device_command(self, command: Any) -> None: """Send device command.""" await self.matter_client.send_device_command( - node_id=self._device_type_instance.node.node_id, - endpoint_id=self._device_type_instance.endpoint_id, + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, command=command, ) @@ -308,15 +260,14 @@ class MatterLight(MatterEntity, LightEntity): color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) - if self._supports_color(): - if hs_color is not None and self._supports_hs_color(): - await self._set_hs_color(hs_color) - elif xy_color is not None and self._supports_xy_color(): - await self._set_xy_color(xy_color) - elif color_temp is not None and self._supports_color_temperature(): - await self._set_color_temp(color_temp) + if hs_color is not None and self.supports_color: + await self._set_hs_color(hs_color) + elif xy_color is not None: + await self._set_xy_color(xy_color) + elif color_temp is not None and self.supports_color_temperature: + 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 @@ -334,106 +285,80 @@ class MatterLight(MatterEntity, LightEntity): def _update_from_device(self) -> None: """Update from device.""" - supports_color = self._supports_color() - supports_color_temperature = ( - self._supports_color_temperature() if supports_color else False - ) - supports_brightness = self._supports_brightness() - if self._attr_supported_color_modes is None: - supported_color_modes = set() - if supports_color: - supported_color_modes.add(ColorMode.XY) - if self._supports_hs_color(): - supported_color_modes.add(ColorMode.HS) - - if supports_color_temperature: - supported_color_modes.add(ColorMode.COLOR_TEMP) - - if supports_brightness: + # work out what (color)features are supported + supported_color_modes: set[ColorMode] = set() + # brightness support + if self._entity_info.endpoint.has_attribute( + None, clusters.LevelControl.Attributes.CurrentLevel + ): supported_color_modes.add(ColorMode.BRIGHTNESS) + # colormode(s) + if self._entity_info.endpoint.has_attribute( + None, clusters.ColorControl.Attributes.ColorMode + ): + # device has some color support, check which color modes + # are supported with the featuremap on the ColorControl cluster + color_feature_map = self.get_matter_attribute_value( + clusters.ColorControl.Attributes.FeatureMap, + ) + if ( + color_feature_map + & clusters.ColorControl.Attributes.CurrentHue.attribute_id + ): + supported_color_modes.add(ColorMode.HS) + if ( + color_feature_map + & clusters.ColorControl.Attributes.CurrentX.attribute_id + ): + supported_color_modes.add(ColorMode.XY) - self._attr_supported_color_modes = ( - supported_color_modes if supported_color_modes else None + # color temperature support detection using the featuremap is not reliable + # (temporary?) fallback to checking the value + if ( + self.get_matter_attribute_value( + clusters.ColorControl.Attributes.ColorTemperatureMireds + ) + is not None + ): + supported_color_modes.add(ColorMode.COLOR_TEMP) + + self._attr_supported_color_modes = supported_color_modes + + LOGGER.debug( + "Supported color modes: %s for %s", + self._attr_supported_color_modes, + self.entity_id, ) - LOGGER.debug( - "Supported color modes: %s for %s", - self._attr_supported_color_modes, - self._device_type_instance, - ) + # set current values - if supports_color: + if self.supports_color: self._attr_color_mode = self._get_color_mode() if self._attr_color_mode == ColorMode.HS: self._attr_hs_color = self._get_hs_color() else: self._attr_xy_color = self._get_xy_color() - if supports_color_temperature: + 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 supports_brightness: + if self.supports_brightness: self._attr_brightness = self._get_brightness() -@dataclass -class MatterLightEntityDescription( - LightEntityDescription, - MatterEntityDescriptionBaseClass, -): - """Matter light entity description.""" - - -# You can't set default values on inherited data classes -MatterLightEntityDescriptionFactory = partial( - MatterLightEntityDescription, entity_cls=MatterLight -) - -# Mapping of a Matter Device type to Light Entity Description. -# A Matter device type (instance) can consist of multiple attributes. -# For example a Color Light which has an attribute to control brightness -# but also for color. - -DEVICE_ENTITY: dict[ - type[device_types.DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], -] = { - device_types.OnOffLight: MatterLightEntityDescriptionFactory( - key=device_types.OnOffLight, - subscribe_attributes=(clusters.OnOff.Attributes.OnOff,), - ), - device_types.DimmableLight: MatterLightEntityDescriptionFactory( - key=device_types.DimmableLight, - subscribe_attributes=( - clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, - ), - ), - device_types.DimmablePlugInUnit: MatterLightEntityDescriptionFactory( - key=device_types.DimmablePlugInUnit, - subscribe_attributes=( - clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, - ), - ), - device_types.ColorTemperatureLight: MatterLightEntityDescriptionFactory( - key=device_types.ColorTemperatureLight, - subscribe_attributes=( - clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, - clusters.ColorControl.Attributes.ColorMode, - clusters.ColorControl.Attributes.ColorTemperatureMireds, - ), - ), - device_types.ExtendedColorLight: MatterLightEntityDescriptionFactory( - key=device_types.ExtendedColorLight, - subscribe_attributes=( - clusters.OnOff.Attributes.OnOff, +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.LIGHT, + entity_description=LightEntityDescription(key="ExtendedMatterLight"), + entity_class=MatterLight, + required_attributes=(clusters.OnOff.Attributes.OnOff,), + optional_attributes=( clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.ColorMode, clusters.ColorControl.Attributes.CurrentHue, @@ -442,5 +367,7 @@ DEVICE_ENTITY: dict[ clusters.ColorControl.Attributes.CurrentY, clusters.ColorControl.Attributes.ColorTemperatureMireds, ), + # restrict device type to prevent discovery in switch platform + not_device_type=(device_types.OnOffPlugInUnit,), ), -} +] diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 73863de5bdbe..b81ac2c62b8d 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==3.0.0"] + "requirements": ["python-matter-server==3.1.0"] } diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py new file mode 100644 index 000000000000..3ce5f1846728 --- /dev/null +++ b/homeassistant/components/matter/models.py @@ -0,0 +1,109 @@ +"""Models used for the Matter integration.""" + +from collections.abc import Callable +from dataclasses import asdict, dataclass +from typing import Any + +from chip.clusters import Objects as clusters +from chip.clusters.Objects import ClusterAttributeDescriptor +from matter_server.client.models.device_types import DeviceType +from matter_server.client.models.node import MatterEndpoint + +from homeassistant.const import Platform +from homeassistant.helpers.entity import EntityDescription + + +class DataclassMustHaveAtLeastOne: + """A dataclass that must have at least one input parameter that is not None.""" + + def __post_init__(self) -> None: + """Post dataclass initialization.""" + if all(val is None for val in asdict(self).values()): + raise ValueError("At least one input parameter must not be None") + + +SensorValueTypes = type[ + clusters.uint | int | clusters.Nullable | clusters.float32 | float +] + + +@dataclass +class MatterEntityInfo: + """Info discovered from (primary) Matter Attribute to create entity.""" + + # MatterEndpoint to which the value(s) belongs + endpoint: MatterEndpoint + + # the home assistant platform for which an entity should be created + platform: Platform + + # All attributes that need to be watched by entity (incl. primary) + attributes_to_watch: list[type[ClusterAttributeDescriptor]] + + # the entity description to use + entity_description: EntityDescription + + # entity class to use to instantiate the entity + entity_class: type + + # [optional] function to call to convert the value from the primary attribute + measurement_to_ha: Callable[[SensorValueTypes], SensorValueTypes] | None = None + + @property + def primary_attribute(self) -> type[ClusterAttributeDescriptor]: + """Return Primary Attribute belonging to the entity.""" + return self.attributes_to_watch[0] + + +@dataclass +class MatterDiscoverySchema: + """Matter discovery schema. + + The Matter endpoint and it's (primary) Attribute for an entity must match these conditions. + """ + + # specify the hass platform for which this scheme applies (e.g. light, sensor) + platform: Platform + + # platform-specific entity description + entity_description: EntityDescription + + # entity class to use to instantiate the entity + entity_class: type + + # DISCOVERY OPTIONS + + # [required] attributes that ALL need to be present + # on the node for this scheme to pass (minimal one == primary) + required_attributes: tuple[type[ClusterAttributeDescriptor], ...] + + # [optional] the value's endpoint must contain this devicetype(s) + device_type: tuple[type[DeviceType] | DeviceType, ...] | None = None + + # [optional] the value's endpoint must NOT contain this devicetype(s) + not_device_type: tuple[type[DeviceType] | DeviceType, ...] | None = None + + # [optional] the endpoint's vendor_id must match ANY of these values + vendor_id: tuple[int, ...] | None = None + + # [optional] the endpoint's product_name must match ANY of these values + product_name: tuple[str, ...] | None = None + + # [optional] the attribute's endpoint_id must match ANY of these values + endpoint_id: tuple[int, ...] | None = None + + # [optional] additional attributes that MAY NOT be present + # on the node for this scheme to pass + absent_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None + + # [optional] additional attributes that may be present + # these attributes are copied over to attributes_to_watch and + # are not discovered by other entities + optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None + + # [optional] bool to specify if this primary value may be discovered + # by multiple platforms + allow_multi: bool = False + + # [optional] function to call to convert the value from the primary attribute + measurement_to_ha: Callable[[Any], Any] | None = None diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index d60d473b0be2..34760fbbf134 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -1,13 +1,8 @@ """Matter sensors.""" from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass -from functools import partial - from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue -from matter_server.client.models import device_types from homeassistant.components.sensor import ( SensorDeviceClass, @@ -27,8 +22,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity, MatterEntityDescriptionBaseClass +from .entity import MatterEntity from .helpers import get_matter +from .models import MatterDiscoverySchema async def async_setup_entry( @@ -45,94 +41,94 @@ class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" _attr_state_class = SensorStateClass.MEASUREMENT - entity_description: MatterSensorEntityDescription @callback def _update_from_device(self) -> None: """Update from device.""" - measurement: Nullable | float | None - measurement = self.get_matter_attribute_value( - # We always subscribe to a single value - self.entity_description.subscribe_attributes[0], - ) - - if measurement == NullValue or measurement is None: - measurement = None - else: - measurement = self.entity_description.measurement_to_ha(measurement) - - self._attr_native_value = measurement + value: Nullable | float | None + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value in (None, NullValue): + value = None + elif value_convert := self._entity_info.measurement_to_ha: + value = value_convert(value) + self._attr_native_value = value -@dataclass -class MatterSensorEntityDescriptionMixin: - """Required fields for sensor device mapping.""" - - measurement_to_ha: Callable[[float], float] - - -@dataclass -class MatterSensorEntityDescription( - SensorEntityDescription, - MatterEntityDescriptionBaseClass, - MatterSensorEntityDescriptionMixin, -): - """Matter Sensor entity description.""" - - -# You can't set default values on inherited data classes -MatterSensorEntityDescriptionFactory = partial( - MatterSensorEntityDescription, entity_cls=MatterSensor -) - - -DEVICE_ENTITY: dict[ - type[device_types.DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], -] = { - device_types.TemperatureSensor: MatterSensorEntityDescriptionFactory( - key=device_types.TemperatureSensor, - name="Temperature", - measurement_to_ha=lambda x: x / 100, - subscribe_attributes=( - clusters.TemperatureMeasurement.Attributes.MeasuredValue, +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=SensorEntityDescription( + key="TemperatureSensor", + name="Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, ), - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - ), - device_types.PressureSensor: MatterSensorEntityDescriptionFactory( - key=device_types.PressureSensor, - name="Pressure", - measurement_to_ha=lambda x: x / 10, - subscribe_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), - native_unit_of_measurement=UnitOfPressure.KPA, - device_class=SensorDeviceClass.PRESSURE, - ), - device_types.FlowSensor: MatterSensorEntityDescriptionFactory( - key=device_types.FlowSensor, - name="Flow", - measurement_to_ha=lambda x: x / 10, - subscribe_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - ), - device_types.HumiditySensor: MatterSensorEntityDescriptionFactory( - key=device_types.HumiditySensor, - name="Humidity", + entity_class=MatterSensor, + required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), measurement_to_ha=lambda x: x / 100, - subscribe_attributes=( + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=SensorEntityDescription( + key="PressureSensor", + name="Pressure", + native_unit_of_measurement=UnitOfPressure.KPA, + device_class=SensorDeviceClass.PRESSURE, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), + measurement_to_ha=lambda x: x / 10, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=SensorEntityDescription( + key="FlowSensor", + name="Flow", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=SensorDeviceClass.WATER, # what is the device class here ? + ), + entity_class=MatterSensor, + required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), + measurement_to_ha=lambda x: x / 10, + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=SensorEntityDescription( + key="HumiditySensor", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + ), + entity_class=MatterSensor, + required_attributes=( clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, ), - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.HUMIDITY, + measurement_to_ha=lambda x: x / 100, ), - device_types.LightSensor: MatterSensorEntityDescriptionFactory( - key=device_types.LightSensor, - name="Light", - measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), - subscribe_attributes=( - clusters.IlluminanceMeasurement.Attributes.MeasuredValue, + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=SensorEntityDescription( + key="LightSensor", + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, ), - native_unit_of_measurement=LIGHT_LUX, - device_class=SensorDeviceClass.ILLUMINANCE, + entity_class=MatterSensor, + required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), + measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), -} + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=SensorEntityDescription( + key="PowerSource", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), + # value has double precision + measurement_to_ha=lambda x: int(x / 2), + ), +] diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 53ae25f8891c..e5c986104397 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -1,8 +1,6 @@ """Matter switches.""" from __future__ import annotations -from dataclasses import dataclass -from functools import partial from typing import Any from chip.clusters import Objects as clusters @@ -18,8 +16,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity, MatterEntityDescriptionBaseClass +from .entity import MatterEntity from .helpers import get_matter +from .models import MatterDiscoverySchema async def async_setup_entry( @@ -35,21 +34,19 @@ async def async_setup_entry( class MatterSwitch(MatterEntity, SwitchEntity): """Representation of a Matter switch.""" - entity_description: MatterSwitchEntityDescription - async def async_turn_on(self, **kwargs: Any) -> None: """Turn switch on.""" await self.matter_client.send_device_command( - node_id=self._device_type_instance.node.node_id, - endpoint_id=self._device_type_instance.endpoint_id, + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, command=clusters.OnOff.Commands.On(), ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn switch off.""" await self.matter_client.send_device_command( - node_id=self._device_type_instance.node.node_id, - endpoint_id=self._device_type_instance.endpoint_id, + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, command=clusters.OnOff.Commands.Off(), ) @@ -57,31 +54,21 @@ class MatterSwitch(MatterEntity, SwitchEntity): def _update_from_device(self) -> None: """Update from device.""" self._attr_is_on = self.get_matter_attribute_value( - clusters.OnOff.Attributes.OnOff + self._entity_info.primary_attribute ) -@dataclass -class MatterSwitchEntityDescription( - SwitchEntityDescription, - MatterEntityDescriptionBaseClass, -): - """Matter Switch entity description.""" - - -# You can't set default values on inherited data classes -MatterSwitchEntityDescriptionFactory = partial( - MatterSwitchEntityDescription, entity_cls=MatterSwitch -) - - -DEVICE_ENTITY: dict[ - type[device_types.DeviceType], - MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass], -] = { - device_types.OnOffPlugInUnit: MatterSwitchEntityDescriptionFactory( - key=device_types.OnOffPlugInUnit, - subscribe_attributes=(clusters.OnOff.Attributes.OnOff,), - device_class=SwitchDeviceClass.OUTLET, +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=SwitchEntityDescription( + key="MatterPlug", device_class=SwitchDeviceClass.OUTLET + ), + entity_class=MatterSwitch, + required_attributes=(clusters.OnOff.Attributes.OnOff,), + # restrict device type to prevent discovery by light + # platform which also uses OnOff cluster + not_device_type=(device_types.OnOffLight, device_types.DimmableLight), ), -} +] diff --git a/requirements_all.txt b/requirements_all.txt index 1befa3fee917..25b58cad484d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2081,7 +2081,7 @@ python-kasa==0.5.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.0.0 +python-matter-server==3.1.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b643171a7f63..d2de02e3df50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1480,7 +1480,7 @@ python-juicenet==1.1.0 python-kasa==0.5.1 # homeassistant.components.matter -python-matter-server==3.0.0 +python-matter-server==3.1.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 4f45862c5cb0..172290125b8d 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -31,7 +31,7 @@ async def test_contact_sensor( """Test contact sensor.""" state = hass.states.get("binary_sensor.mock_contact_sensor_contact") assert state - assert state.state == "on" + assert state.state == "off" set_node_attribute(contact_sensor_node, 1, 69, 0, False) await trigger_subscription_callback( @@ -40,7 +40,7 @@ async def test_contact_sensor( state = hass.states.get("binary_sensor.mock_contact_sensor_contact") assert state - assert state.state == "off" + assert state.state == "on" @pytest.fixture(name="occupancy_sensor_node") diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index 8f849c85941e..2ccb818b3334 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -26,7 +26,7 @@ async def test_get_device_id( node = await setup_integration_with_node_fixture( hass, "device_diagnostics", matter_client ) - device_id = get_device_id(matter_client.server_info, node.node_devices[0]) + device_id = get_device_id(matter_client.server_info, node.endpoints[0]) assert device_id == "00000000000004D2-0000000000000005-MatterNodeDevice" diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index a5a858b0b119..cab1f59f837f 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -297,10 +297,14 @@ async def test_extended_color_light( matter_client.send_device_command.assert_has_calls( [ call( - node_id=light_node.node_id, + node_id=1, endpoint_id=1, - command=clusters.ColorControl.Commands.MoveToHueAndSaturation( - hue=0, saturation=0, transitionTime=0 + command=clusters.ColorControl.Commands.MoveToColor( + colorX=21168, + colorY=21561, + transitionTime=0, + optionsMask=0, + optionsOverride=0, ), ), call( diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index deaaf62c9720..24b6662108c6 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -121,14 +121,14 @@ async def test_light_sensor( light_sensor_node: MatterNode, ) -> None: """Test light sensor.""" - state = hass.states.get("sensor.mock_light_sensor_light") + state = hass.states.get("sensor.mock_light_sensor_illuminance") assert state assert state.state == "1.3" set_node_attribute(light_sensor_node, 1, 1024, 0, 3000) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("sensor.mock_light_sensor_light") + state = hass.states.get("sensor.mock_light_sensor_illuminance") assert state assert state.state == "2.0" From d5f171349809ce93df1fe23565c2090ebd82af42 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 23 Feb 2023 18:40:10 -0600 Subject: [PATCH 0017/1058] Include binary_sensor in default Assist exposed domains (#88682) --- homeassistant/components/conversation/const.py | 3 +++ .../components/conversation/default_agent.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index b79a557698f1..1cae975c957d 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -3,6 +3,7 @@ DOMAIN = "conversation" DEFAULT_EXPOSED_DOMAINS = { + "binary_sensor", "climate", "cover", "fan", @@ -16,3 +17,5 @@ DEFAULT_EXPOSED_DOMAINS = { "vacuum", "water_heater", } + +DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 2c531fea14d8..3db7b013bdca 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -28,7 +28,7 @@ from homeassistant.helpers import ( from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult -from .const import DEFAULT_EXPOSED_DOMAINS, DOMAIN +from .const import DEFAULT_EXPOSED_ATTRIBUTES, DEFAULT_EXPOSED_DOMAINS, DOMAIN _LOGGER = logging.getLogger(__name__) _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" @@ -467,6 +467,12 @@ class DefaultAgent(AbstractConversationAgent): for state in states: # Checked against "requires_context" and "excludes_context" in hassil context = {"domain": state.domain} + if state.attributes: + # Include some attributes + for attr_key, attr_value in state.attributes.items(): + if attr_key not in DEFAULT_EXPOSED_ATTRIBUTES: + continue + context[attr_key] = attr_value entity = entities.async_get(state.entity_id) if entity is not None: @@ -506,6 +512,9 @@ class DefaultAgent(AbstractConversationAgent): for alias in area.aliases: area_names.append((alias, area.id)) + _LOGGER.debug("Exposed areas: %s", area_names) + _LOGGER.debug("Exposed entities: %s", entity_names) + self._slot_lists = { "area": TextSlotList.from_tuples(area_names, allow_template=False), "name": TextSlotList.from_tuples(entity_names, allow_template=False), From a71487a42bd2483278a14d30d2520900dc712f4d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 23 Feb 2023 19:50:23 -0600 Subject: [PATCH 0018/1058] Make a copy of matching states so translated state names can be used (#88683) --- .../components/conversation/default_agent.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3db7b013bdca..3be3f8cfc6fa 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -227,7 +227,21 @@ class DefaultAgent(AbstractConversationAgent): intent_response: intent.IntentResponse, recognize_result: RecognizeResult, ) -> str: - all_states = intent_response.matched_states + intent_response.unmatched_states + # Make copies of the states here so we can add translated names for responses. + matched: list[core.State] = [] + + for state in intent_response.matched_states: + state_copy = core.State.from_dict(state.as_dict()) + if state_copy is not None: + matched.append(state_copy) + + unmatched: list[core.State] = [] + for state in intent_response.unmatched_states: + state_copy = core.State.from_dict(state.as_dict()) + if state_copy is not None: + unmatched.append(state_copy) + + all_states = matched + unmatched domains = {state.domain for state in all_states} translations = await translation.async_get_translations( self.hass, language, "state", domains @@ -262,13 +276,11 @@ class DefaultAgent(AbstractConversationAgent): "query": { # Entity states that matched the query (e.g, "on") "matched": [ - template.TemplateState(self.hass, state) - for state in intent_response.matched_states + template.TemplateState(self.hass, state) for state in matched ], # Entity states that did not match the query "unmatched": [ - template.TemplateState(self.hass, state) - for state in intent_response.unmatched_states + template.TemplateState(self.hass, state) for state in unmatched ], }, } From f0b029c363ee12a9f05ba19b7be693ab7a722fa9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Feb 2023 19:52:31 -0600 Subject: [PATCH 0019/1058] Bump mopeka_iot_ble to 0.4.1 (#88680) * Bump mopeka_iot_ble to 0.4.1 closes #88232 * adjust tests --- homeassistant/components/mopeka/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/mopeka/__init__.py | 10 ++++ tests/components/mopeka/test_sensor.py | 46 +++++++++++++++++-- 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index f4b82be0acea..711041921536 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/mopeka", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mopeka_iot_ble==0.4.0"] + "requirements": ["mopeka_iot_ble==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 25b58cad484d..b9106e5f3ea9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1144,7 +1144,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.0 # homeassistant.components.mopeka -mopeka_iot_ble==0.4.0 +mopeka_iot_ble==0.4.1 # homeassistant.components.motion_blinds motionblinds==0.6.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2de02e3df50..5dc78de2796c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -849,7 +849,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.0 # homeassistant.components.mopeka -mopeka_iot_ble==0.4.0 +mopeka_iot_ble==0.4.1 # homeassistant.components.motion_blinds motionblinds==0.6.17 diff --git a/tests/components/mopeka/__init__.py b/tests/components/mopeka/__init__.py index 389400cc5115..3446b1dc66b3 100644 --- a/tests/components/mopeka/__init__.py +++ b/tests/components/mopeka/__init__.py @@ -23,6 +23,16 @@ PRO_SERVICE_INFO = BluetoothServiceInfo( source="local", ) +PRO_UNUSABLE_SIGNAL_SERVICE_INFO = BluetoothServiceInfo( + name="", + address="aa:bb:cc:dd:ee:ff", + rssi=-60, + manufacturer_data={89: b"\x08rF\x00\x00\xe0\xf5\t\xf0\xd8"}, + service_data={}, + service_uuids=["0000fee5-0000-1000-8000-00805f9b34fb"], + source="local", +) + PRO_GOOD_SIGNAL_SERVICE_INFO = BluetoothServiceInfo( name="", diff --git a/tests/components/mopeka/test_sensor.py b/tests/components/mopeka/test_sensor.py index 7e2a81d31007..626aa44efd40 100644 --- a/tests/components/mopeka/test_sensor.py +++ b/tests/components/mopeka/test_sensor.py @@ -10,14 +10,52 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import PRO_GOOD_SIGNAL_SERVICE_INFO, PRO_SERVICE_INFO +from . import ( + PRO_GOOD_SIGNAL_SERVICE_INFO, + PRO_SERVICE_INFO, + PRO_UNUSABLE_SIGNAL_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors_bad_signal(hass: HomeAssistant) -> None: - """Test setting up creates the sensors when there is bad signal.""" +async def test_sensors_unusable_signal(hass: HomeAssistant) -> None: + """Test setting up creates the sensors when there is unusable signal.""" + 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("sensor")) == 0 + inject_bluetooth_service_info(hass, PRO_UNUSABLE_SIGNAL_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 4 + + temp_sensor = hass.states.get("sensor.pro_plus_eeff_temperature") + temp_sensor_attrs = temp_sensor.attributes + assert temp_sensor.state == "30" + assert temp_sensor_attrs[ATTR_FRIENDLY_NAME] == "Pro Plus EEFF Temperature" + assert temp_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert temp_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + tank_sensor = hass.states.get("sensor.pro_plus_eeff_tank_level") + tank_sensor_attrs = tank_sensor.attributes + assert tank_sensor.state == STATE_UNKNOWN + assert tank_sensor_attrs[ATTR_FRIENDLY_NAME] == "Pro Plus EEFF Tank Level" + assert tank_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == UnitOfLength.MILLIMETERS + assert tank_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sensors_poor_signal(hass: HomeAssistant) -> None: + """Test setting up creates the sensors when there is poor signal.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", @@ -41,7 +79,7 @@ async def test_sensors_bad_signal(hass: HomeAssistant) -> None: tank_sensor = hass.states.get("sensor.pro_plus_eeff_tank_level") tank_sensor_attrs = tank_sensor.attributes - assert tank_sensor.state == STATE_UNKNOWN + assert tank_sensor.state == "0" assert tank_sensor_attrs[ATTR_FRIENDLY_NAME] == "Pro Plus EEFF Tank Level" assert tank_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == UnitOfLength.MILLIMETERS assert tank_sensor_attrs[ATTR_STATE_CLASS] == "measurement" From 9575cd91614feef9cbb4a046c893569bd0e434c8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 23 Feb 2023 20:52:53 -0500 Subject: [PATCH 0020/1058] Name the Yellow-internal radio and multi-PAN addon as ZHA serial ports (#88208) * Expose the Yellow-internal radio and multi-PAN addon as named serial ports * Remove the serial number if it isn't available * Use consistent names for the addon and Zigbee radio * Add `homeassistant_hardware` and `_yellow` as `after_dependencies` * Handle `hassio` not existing when listing serial ports * Add unit tests --- homeassistant/components/zha/config_flow.py | 46 +++++++++++++++++++-- homeassistant/components/zha/manifest.json | 8 +++- tests/components/zha/test_config_flow.py | 44 ++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 554a94b84504..05dc67314ed7 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -7,6 +7,7 @@ import json from typing import Any import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo import voluptuous as vol import zigpy.backups from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH @@ -14,9 +15,13 @@ from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries from homeassistant.components import onboarding, usb, zeroconf from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.components.hassio import AddonError, AddonState +from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon +from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowHandler, FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import FileSelector, FileSelectorConfig from homeassistant.util import dt @@ -72,6 +77,41 @@ def _format_backup_choice( return f"{dt.as_local(backup.backup_time).strftime('%c')} ({identifier})" +async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: + """List all serial ports, including the Yellow radio and the multi-PAN addon.""" + ports = await hass.async_add_executor_job(serial.tools.list_ports.comports) + + # Add useful info to the Yellow's serial port selection screen + try: + yellow_hardware.async_info(hass) + except HomeAssistantError: + pass + else: + yellow_radio = next(p for p in ports if p.device == "/dev/ttyAMA1") + yellow_radio.description = "Yellow Zigbee module" + yellow_radio.manufacturer = "Nabu Casa" + + # Present the multi-PAN addon as a setup option, if it's available + addon_manager = silabs_multiprotocol_addon.get_addon_manager(hass) + + try: + addon_info = await addon_manager.async_get_addon_info() + except (AddonError, KeyError): + addon_info = None + + if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: + addon_port = ListPortInfo( + device=silabs_multiprotocol_addon.get_zigbee_socket(hass, addon_info), + skip_link_detection=True, + ) + + addon_port.description = "Multiprotocol add-on" + addon_port.manufacturer = "Nabu Casa" + ports.append(addon_port) + + return ports + + class BaseZhaFlow(FlowHandler): """Mixin for common ZHA flow steps and forms.""" @@ -120,9 +160,9 @@ class BaseZhaFlow(FlowHandler): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose a serial port.""" - ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + ports = await list_serial_ports(self.hass) list_of_ports = [ - f"{p}, s/n: {p.serial_number or 'n/a'}" + f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}" + (f" - {p.manufacturer}" if p.manufacturer else "") for p in ports ] @@ -146,7 +186,7 @@ class BaseZhaFlow(FlowHandler): return await self.async_step_manual_pick_radio_type() self._title = ( - f"{port.description}, s/n: {port.serial_number or 'n/a'}" + f"{port.description}{', s/n: ' + port.serial_number if port.serial_number else ''}" f" - {port.manufacturer}" if port.manufacturer else "" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 090e171835d2..c3aefe5987a3 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -1,7 +1,13 @@ { "domain": "zha", "name": "Zigbee Home Automation", - "after_dependencies": ["onboarding", "usb", "zeroconf"], + "after_dependencies": [ + "onboarding", + "usb", + "zeroconf", + "homeassistant_hardware", + "homeassistant_yellow" + ], "codeowners": ["@dmulcahey", "@adminiuga", "@puddly"], "config_flow": true, "dependencies": ["file_upload"], diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 0f7363bb0117..d95564519969 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -15,6 +15,7 @@ import zigpy.types from homeassistant import config_entries from homeassistant.components import ssdp, usb, zeroconf +from homeassistant.components.hassio import AddonState from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.core.const import ( @@ -1840,3 +1841,46 @@ async def test_options_flow_migration_reset_old_adapter( user_input={}, ) assert result4["step_id"] == "choose_serial_port" + + +async def test_config_flow_port_yellow_port_name(hass: HomeAssistant) -> None: + """Test config flow serial port name for Yellow Zigbee radio.""" + port = com_port(device="/dev/ttyAMA1") + port.serial_number = None + port.manufacturer = None + port.description = None + + with patch( + "homeassistant.components.zha.config_flow.yellow_hardware.async_info" + ), patch("serial.tools.list_ports.comports", MagicMock(return_value=[port])): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert ( + result["data_schema"].schema["path"].container[0] + == "/dev/ttyAMA1 - Yellow Zigbee module - Nabu Casa" + ) + + +async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> None: + """Test config flow serial port name for multiprotocol add-on.""" + + with patch( + "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info" + ) as async_get_addon_info, patch( + "serial.tools.list_ports.comports", MagicMock(return_value=[]) + ): + async_get_addon_info.return_value.state = AddonState.RUNNING + async_get_addon_info.return_value.hostname = "core-silabs-multiprotocol" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert ( + result["data_schema"].schema["path"].container[0] + == "socket://core-silabs-multiprotocol:9999 - Multiprotocol add-on - Nabu Casa" + ) From af49b98475a11c23b8c5c6dd6fcb3159c234a6e0 Mon Sep 17 00:00:00 2001 From: David Poll Date: Thu, 23 Feb 2023 19:14:28 -0800 Subject: [PATCH 0021/1058] Enable jinja loop controls (break/continue) (#88625) Enables jinja loop controls (break/continue) --- homeassistant/helpers/template.py | 1 + tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9aafe53925c9..2e112706fba8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2063,6 +2063,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.template_cache: weakref.WeakValueDictionary[ str | jinja2.nodes.Template, CodeType | str | None ] = weakref.WeakValueDictionary() + self.add_extension("jinja2.ext.loopcontrols") self.filters["round"] = forgiving_round self.filters["multiply"] = multiply self.filters["log"] = logarithm diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index ec6714bafe14..2434b2aed152 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -243,6 +243,26 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: ) +def test_loop_controls(hass: HomeAssistant) -> None: + """Test that loop controls are enabled.""" + assert ( + template.Template( + """ + {%- for v in range(10) %} + {%- if v == 1 -%} + {%- continue -%} + {%- elif v == 3 -%} + {%- break -%} + {%- endif -%} + {{ v }} + {%- endfor -%} + """, + hass, + ).async_render() + == "02" + ) + + def test_float_function(hass: HomeAssistant) -> None: """Test float function.""" hass.states.async_set("sensor.temperature", "12") From 2f826a6f86add77c43b3b738daafe7a46e551093 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Feb 2023 04:15:20 +0100 Subject: [PATCH 0022/1058] Modernize uptime tests (#88636) * Modernize uptime tests * Fix tests --- .../uptime/snapshots/test_config_flow.ambr | 34 ++++++++ .../uptime/snapshots/test_sensor.ambr | 85 +++++++++++++++++++ tests/components/uptime/test_config_flow.py | 10 ++- tests/components/uptime/test_sensor.py | 31 ++++--- 4 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 tests/components/uptime/snapshots/test_config_flow.ambr create mode 100644 tests/components/uptime/snapshots/test_sensor.ambr diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr new file mode 100644 index 000000000000..ac4b7396839f --- /dev/null +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_full_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + }), + 'data': dict({ + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'uptime', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'uptime', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Uptime', + 'unique_id': None, + 'version': 1, + }), + 'title': 'Uptime', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..539ba640d806 --- /dev/null +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -0,0 +1,85 @@ +# serializer version: 1 +# name: test_uptime_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Uptime', + }), + 'context': , + 'entity_id': 'sensor.uptime', + 'last_changed': , + 'last_updated': , + 'state': '2022-03-01T00:00:00+00:00', + }) +# --- +# name: test_uptime_sensor.1 + 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.uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'uptime', + 'supported_features': 0, + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_uptime_sensor.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'Uptime', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_uptime_sensor.3 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'manufacturer': None, + 'model': None, + 'name': 'Uptime', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/uptime/test_config_flow.py b/tests/components/uptime/test_config_flow.py index 4a7bb11b8392..9f1c3931d181 100644 --- a/tests/components/uptime/test_config_flow.py +++ b/tests/components/uptime/test_config_flow.py @@ -1,5 +1,7 @@ """Tests for the Uptime config flow.""" -from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.uptime.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -9,9 +11,10 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_setup_entry") async def test_full_user_flow( hass: HomeAssistant, - mock_setup_entry: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -27,8 +30,7 @@ async def test_full_user_flow( ) assert result2.get("type") == FlowResultType.CREATE_ENTRY - assert result2.get("title") == "Uptime" - assert result2.get("data") == {} + assert result2 == snapshot async def test_single_instance_allowed( diff --git a/tests/components/uptime/test_sensor.py b/tests/components/uptime/test_sensor.py index 053224c3b4fb..41c097badd1b 100644 --- a/tests/components/uptime/test_sensor.py +++ b/tests/components/uptime/test_sensor.py @@ -1,36 +1,35 @@ """The tests for the uptime sensor platform.""" import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.uptime.const import DOMAIN -from homeassistant.const import ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry +@pytest.mark.usefixtures("init_integration") @pytest.mark.freeze_time("2022-03-01 00:00:00+00:00") async def test_uptime_sensor( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test Uptime sensor.""" - state = hass.states.get("sensor.uptime") - assert state + + assert (state := hass.states.get("sensor.uptime")) assert state.state == "2022-03-01T00:00:00+00:00" - assert state.attributes["friendly_name"] == "Uptime" - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + assert state == snapshot - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("sensor.uptime") - assert entry - assert entry.unique_id == init_integration.entry_id + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot(exclude=props("unique_id")) + assert entity_entry.unique_id == init_integration.entry_id - device_registry = dr.async_get(hass) - assert entry.device_id - device_entry = device_registry.async_get(entry.device_id) - assert device_entry + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot(exclude=props("identifiers")) assert device_entry.identifiers == {(DOMAIN, init_integration.entry_id)} - assert device_entry.name == init_integration.title - assert device_entry.entry_type == dr.DeviceEntryType.SERVICE From d90ee8511878cb63d3b345940724147a74e28168 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 24 Feb 2023 04:30:51 +0100 Subject: [PATCH 0023/1058] Allow conditions to be implemented in platforms (#88509) * Allow conditions to be implemented in platforms * Update tests * Tweak typing * Rebase fixes --- .../components/device_automation/condition.py | 20 +--- homeassistant/helpers/condition.py | 93 ++++++++++++++----- tests/helpers/test_script.py | 16 +--- 3 files changed, 77 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 3856458c3dd7..f819668f0905 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import ConditionProtocol, trace_condition_function from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -17,24 +18,13 @@ if TYPE_CHECKING: from homeassistant.helpers import condition -class DeviceAutomationConditionProtocol(Protocol): +class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol): """Define the format of device_condition modules. - Each module must define either CONDITION_SCHEMA or async_validate_condition_config. + Each module must define either CONDITION_SCHEMA or async_validate_condition_config + from ConditionProtocol. """ - CONDITION_SCHEMA: vol.Schema - - async def async_validate_condition_config( - self, hass: HomeAssistant, config: ConfigType - ) -> ConfigType: - """Validate config.""" - - def async_condition_from_config( - self, hass: HomeAssistant, config: ConfigType - ) -> condition.ConditionCheckerType: - """Evaluate state based on configuration.""" - async def async_get_condition_capabilities( self, hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: @@ -62,4 +52,4 @@ async def async_condition_from_config( platform = await async_get_device_automation_platform( hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION ) - return platform.async_condition_from_config(hass, config) + return trace_condition_function(platform.async_condition_from_config(hass, config)) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 7513e2b0087e..0029a9c906bf 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -7,15 +7,13 @@ from collections.abc import Callable, Container, Generator from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft -import logging import re import sys -from typing import Any, cast +from typing import Any, Protocol, cast import voluptuous as vol from homeassistant.components import zone as zone_cmp -from homeassistant.components.device_automation import condition as device_condition from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -55,6 +53,7 @@ from homeassistant.exceptions import ( HomeAssistantError, TemplateError, ) +from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import run_callback_threadsafe import homeassistant.util.dt as dt_util @@ -77,12 +76,44 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" FROM_CONFIG_FORMAT = "{}_from_config" VALIDATE_CONFIG_FORMAT = "{}_validate_config" -_LOGGER = logging.getLogger(__name__) +_PLATFORM_ALIASES = { + "and": None, + "device": "device_automation", + "not": None, + "numeric_state": None, + "or": None, + "state": None, + "sun": None, + "template": None, + "time": None, + "trigger": None, + "zone": None, +} INPUT_ENTITY_ID = re.compile( r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(? ConfigType: + """Validate config.""" + + def async_condition_from_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConditionCheckerType: + """Evaluate state based on configuration.""" + + ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] @@ -152,6 +183,27 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke return wrapper +async def _async_get_condition_platform( + hass: HomeAssistant, config: ConfigType +) -> ConditionProtocol | None: + platform = config[CONF_CONDITION] + platform = _PLATFORM_ALIASES.get(platform, platform) + if platform is None: + return None + try: + integration = await async_get_integration(hass, platform) + except IntegrationNotFound: + raise HomeAssistantError( + f'Invalid condition "{platform}" specified {config}' + ) from None + try: + return integration.get_platform("condition") + except ImportError: + raise HomeAssistantError( + f"Integration '{platform}' does not provide condition support" + ) from None + + async def async_from_config( hass: HomeAssistant, config: ConfigType, @@ -160,15 +212,18 @@ async def async_from_config( Should be run on the event loop. """ - condition = config.get(CONF_CONDITION) - for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): - factory = getattr(sys.modules[__name__], fmt.format(condition), None) + factory: Any = None + platform = await _async_get_condition_platform(hass, config) - if factory: - break + if platform is None: + condition = config.get(CONF_CONDITION) + for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): + factory = getattr(sys.modules[__name__], fmt.format(condition), None) - if factory is None: - raise HomeAssistantError(f'Invalid condition "{condition}" specified {config}') + if factory: + break + else: + factory = platform.async_condition_from_config # Check if condition is not enabled if not config.get(CONF_ENABLED, True): @@ -928,14 +983,6 @@ def zone_from_config(config: ConfigType) -> ConditionCheckerType: return if_in_zone -async def async_device_from_config( - hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: - """Test a device condition.""" - checker = await device_condition.async_condition_from_config(hass, config) - return trace_condition_function(checker) - - async def async_trigger_from_config( hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: @@ -991,10 +1038,10 @@ async def async_validate_condition_config( config["conditions"] = conditions return config - if condition == "device": - return await device_condition.async_validate_condition_config(hass, config) - - if condition in ("numeric_state", "state"): + platform = await _async_get_condition_platform(hass, config) + if platform is not None and hasattr(platform, "async_validate_condition_config"): + return await platform.async_validate_condition_config(hass, config) + if platform is None and condition in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], getattr(sys.modules[__name__], VALIDATE_CONFIG_FORMAT.format(condition)), diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 5328a1d38ed5..0521bc722cde 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -2406,13 +2406,7 @@ async def test_repeat_var_in_condition(hass: HomeAssistant, condition) -> None: script_obj = script.Script( hass, cv.SCRIPT_SCHEMA(sequence), "Test Name", "test_domain" ) - - with mock.patch( - "homeassistant.helpers.condition._LOGGER.error", - side_effect=AssertionError("Template Error"), - ): - await script_obj.async_run(context=Context()) - + await script_obj.async_run(context=Context()) assert len(events) == 2 if condition == "while": @@ -2545,13 +2539,7 @@ async def test_repeat_nested( ] ) script_obj = script.Script(hass, sequence, "Test Name", "test_domain") - - with mock.patch( - "homeassistant.helpers.condition._LOGGER.error", - side_effect=AssertionError("Template Error"), - ): - await script_obj.async_run(variables, Context()) - + await script_obj.async_run(variables, Context()) assert len(events) == 10 assert events[0].data == first_last assert events[-1].data == first_last From 0ae2fdc08b82ff424fbee606cd3c158e6273c66a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Feb 2023 22:00:08 -0600 Subject: [PATCH 0024/1058] Switch samsungtv to use async_timeout to avoid task creation (#88679) wait_for creates a task, async_timeout does the same work and avoids the task creation --- homeassistant/components/samsungtv/media_player.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 3e544b181f15..59b4131af150 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -6,6 +6,7 @@ from collections.abc import Coroutine, Sequence from datetime import datetime, timedelta from typing import Any +import async_timeout from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable from async_upnp_client.client_factory import UpnpFactory @@ -250,7 +251,8 @@ class SamsungTVDevice(MediaPlayerEntity): # enter it unless we have to (Python 3.11 will have zero cost try) return try: - await asyncio.wait_for(self._app_list_event.wait(), APP_LIST_DELAY) + async with async_timeout.timeout(APP_LIST_DELAY): + await self._app_list_event.wait() except asyncio.TimeoutError as err: # No need to try again self._app_list_event.set() From 84823d2fcfa7f5d7e56d9259144b46061c051d69 Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Fri, 24 Feb 2023 07:32:59 +0100 Subject: [PATCH 0025/1058] Upgrade caldav to 1.1.3 (#88681) * Update caldav to 1.1.3 * update caldav to 1.1.3 * update caldav to 1.1.3 --------- Co-authored-by: Allen Porter --- homeassistant/components/caldav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 5008325d5e58..e44251ed7c2f 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.1.1"] + "requirements": ["caldav==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b9106e5f3ea9..56dca59e9636 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -504,7 +504,7 @@ btsmarthub_devicelist==0.2.3 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.1.1 +caldav==1.1.3 # homeassistant.components.circuit circuit-webhook==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5dc78de2796c..e734c5f11040 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -405,7 +405,7 @@ bthome-ble==2.5.2 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.1.1 +caldav==1.1.3 # homeassistant.components.co2signal co2signal==0.4.2 From ee8f7468083dc3d08341cb1a892b7ecb7dbeb85e Mon Sep 17 00:00:00 2001 From: Thomas Dietrich Date: Fri, 24 Feb 2023 14:11:40 +0100 Subject: [PATCH 0026/1058] Change statistics component ownership (#88692) --- CODEOWNERS | 4 ++-- homeassistant/components/statistics/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index cb559a7d7bb4..94360a4f45b8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1138,8 +1138,8 @@ build.json @home-assistant/supervisor /tests/components/starline/ @anonym-tsk /homeassistant/components/starlink/ @boswelja /tests/components/starlink/ @boswelja -/homeassistant/components/statistics/ @fabaff @ThomDietrich -/tests/components/statistics/ @fabaff @ThomDietrich +/homeassistant/components/statistics/ @ThomDietrich +/tests/components/statistics/ @ThomDietrich /homeassistant/components/steam_online/ @tkdrob /tests/components/steam_online/ @tkdrob /homeassistant/components/steamist/ @bdraco diff --git a/homeassistant/components/statistics/manifest.json b/homeassistant/components/statistics/manifest.json index 6a41dec447bc..04b5277ecf51 100644 --- a/homeassistant/components/statistics/manifest.json +++ b/homeassistant/components/statistics/manifest.json @@ -2,7 +2,7 @@ "domain": "statistics", "name": "Statistics", "after_dependencies": ["recorder"], - "codeowners": ["@fabaff", "@ThomDietrich"], + "codeowners": ["@ThomDietrich"], "documentation": "https://www.home-assistant.io/integrations/statistics", "iot_class": "local_polling", "quality_scale": "internal" From 753c790a250013fddf3d23978dab3638fb216447 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Feb 2023 14:13:03 +0100 Subject: [PATCH 0027/1058] Use async_timeout in integrations (#88697) --- homeassistant/components/hlk_sw16/config_flow.py | 4 +++- homeassistant/components/opentherm_gw/__init__.py | 7 +++---- homeassistant/components/opentherm_gw/config_flow.py | 7 +++---- homeassistant/components/ping/binary_sensor.py | 6 +++--- homeassistant/components/squeezebox/config_flow.py | 4 +++- homeassistant/components/upnp/__init__.py | 4 +++- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py index 833894726077..4920e1542d5e 100644 --- a/homeassistant/components/hlk_sw16/config_flow.py +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -1,6 +1,7 @@ """Config flow for HLK-SW16.""" import asyncio +import async_timeout from hlk_sw16 import create_hlk_sw16_connection import voluptuous as vol @@ -35,7 +36,8 @@ async def connect_client(hass, user_input): reconnect_interval=DEFAULT_RECONNECT_INTERVAL, keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, ) - return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT) + async with async_timeout.timeout(CONNECTION_TIMEOUT): + return await client_aw async def validate_input(hass: HomeAssistant, user_input): diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 51071c9a0a15..aebf1e26c339 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -3,6 +3,7 @@ import asyncio from datetime import date, datetime import logging +import async_timeout import pyotgw import pyotgw.vars as gw_vars from serial import SerialException @@ -112,10 +113,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.add_update_listener(options_updated) try: - await asyncio.wait_for( - gateway.connect_and_subscribe(), - timeout=CONNECTION_TIMEOUT, - ) + async with async_timeout.timeout(CONNECTION_TIMEOUT): + await gateway.connect_and_subscribe() except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: await gateway.cleanup() raise ConfigEntryNotReady( diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index ed9b62ff4993..87a510216579 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import async_timeout import pyotgw from pyotgw import vars as gw_vars from serial import SerialException @@ -68,10 +69,8 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT) try: - await asyncio.wait_for( - test_connection(), - timeout=CONNECTION_TIMEOUT, - ) + async with async_timeout.timeout(CONNECTION_TIMEOUT): + await test_connection() except asyncio.TimeoutError: return self._show_form({"base": "timeout_connect"}) except (ConnectionError, SerialException): diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 7500d9988af0..c8b4ce5a2043 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -8,6 +8,7 @@ import logging import re from typing import Any +import async_timeout from icmplib import NameLookupError, async_ping import voluptuous as vol @@ -230,9 +231,8 @@ class PingDataSubProcess(PingData): close_fds=False, # required for posix_spawn ) try: - out_data, out_error = await asyncio.wait_for( - pinger.communicate(), self._count + PING_TIMEOUT - ) + async with async_timeout.timeout(self._count + PING_TIMEOUT): + out_data, out_error = await pinger.communicate() if out_data: _LOGGER.debug( diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 1411b8bc7827..bb175ee00be1 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -4,6 +4,7 @@ from http import HTTPStatus import logging from typing import TYPE_CHECKING +import async_timeout from pysqueezebox import Server, async_discover import voluptuous as vol @@ -130,7 +131,8 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # no host specified, see if we can discover an unconfigured LMS server try: - await asyncio.wait_for(self._discover(), timeout=TIMEOUT) + async with async_timeout.timeout(TIMEOUT): + await self._discover() return await self.async_step_edit() except asyncio.TimeoutError: errors["base"] = "no_server_found" diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index ac9fe19f4e7c..7ddec4e3fbee 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from datetime import timedelta +import async_timeout from async_upnp_client.exceptions import UpnpConnectionError from homeassistant.components import ssdp @@ -70,7 +71,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - await asyncio.wait_for(device_discovered_event.wait(), timeout=10) + async with async_timeout.timeout(10): + await device_discovered_event.wait() except asyncio.TimeoutError as err: raise ConfigEntryNotReady(f"Device not discovered: {usn}") from err finally: From ba929dfc79b32f4bd7aad1711224eb4385ad7745 Mon Sep 17 00:00:00 2001 From: StefanIacobLivisi <109964424+StefanIacobLivisi@users.noreply.github.com> Date: Fri, 24 Feb 2023 16:22:30 +0200 Subject: [PATCH 0028/1058] Bump aiolivisi to 0.0.16 (#88700) Increment aiolivisi library version --- homeassistant/components/livisi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 849cfdad5c99..6cdebeb307f6 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", "iot_class": "local_polling", - "requirements": ["aiolivisi==0.0.15"] + "requirements": ["aiolivisi==0.0.16"] } diff --git a/requirements_all.txt b/requirements_all.txt index 56dca59e9636..ea58bc384d83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -202,7 +202,7 @@ aiolifx_effects==0.3.1 aiolifx_themes==0.4.0 # homeassistant.components.livisi -aiolivisi==0.0.15 +aiolivisi==0.0.16 # homeassistant.components.lookin aiolookin==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e734c5f11040..6ced08f7fc4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -183,7 +183,7 @@ aiolifx_effects==0.3.1 aiolifx_themes==0.4.0 # homeassistant.components.livisi -aiolivisi==0.0.15 +aiolivisi==0.0.16 # homeassistant.components.lookin aiolookin==1.0.0 From fdc06c2fc2741232418be9454c66ad0e3094d4bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Feb 2023 16:54:02 +0100 Subject: [PATCH 0029/1058] Improve type hint in webostv trigger (#88599) Improve type hint in webostv trigger --- homeassistant/components/webostv/trigger.py | 20 +++++++++---------- .../components/webostv/triggers/__init__.py | 11 ---------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/webostv/trigger.py b/homeassistant/components/webostv/trigger.py index 5441917cc313..4d237993f959 100644 --- a/homeassistant/components/webostv/trigger.py +++ b/homeassistant/components/webostv/trigger.py @@ -5,24 +5,28 @@ from typing import cast from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) from homeassistant.helpers.typing import ConfigType -from .triggers import TriggersPlatformModule, turn_on +from .triggers import turn_on TRIGGERS = { "turn_on": turn_on, } -def _get_trigger_platform(config: ConfigType) -> TriggersPlatformModule: +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: """Return trigger platform.""" platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: raise ValueError( f"Unknown webOS Smart TV trigger platform {config[CONF_PLATFORM]}" ) - return cast(TriggersPlatformModule, TRIGGERS[platform_split[1]]) + return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) async def async_validate_trigger_config( @@ -41,10 +45,4 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach trigger of specified platform.""" platform = _get_trigger_platform(config) - assert hasattr(platform, "async_attach_trigger") - return cast( - CALLBACK_TYPE, - await getattr(platform, "async_attach_trigger")( - hass, config, action, trigger_info - ), - ) + return await platform.async_attach_trigger(hass, config, action, trigger_info) diff --git a/homeassistant/components/webostv/triggers/__init__.py b/homeassistant/components/webostv/triggers/__init__.py index 710caffef7a8..d8c5a28ef3f8 100644 --- a/homeassistant/components/webostv/triggers/__init__.py +++ b/homeassistant/components/webostv/triggers/__init__.py @@ -1,12 +1 @@ """webOS Smart TV triggers.""" -from __future__ import annotations - -from typing import Protocol - -import voluptuous as vol - - -class TriggersPlatformModule(Protocol): - """Protocol type for the triggers platform.""" - - TRIGGER_SCHEMA: vol.Schema From ee7dfdae30e055ff728fbb8621bd3ff98853cf4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 10:09:12 -0600 Subject: [PATCH 0030/1058] Bump aioesphomeapi to 13.4.1 (#88703) changelog: https://github.com/esphome/aioesphomeapi/releases/tag/v13.4.1 --- 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 94b111ab4205..fde8c26ba5ea 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.4.0", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.4.1", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ea58bc384d83..032305ac4656 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.0 +aioesphomeapi==13.4.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ced08f7fc4c..867d89c6b7eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.0 +aioesphomeapi==13.4.1 # homeassistant.components.flo aioflo==2021.11.0 From e69091c6db561d84b6b8d9b45dace7e8fee25141 Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Fri, 24 Feb 2023 08:51:48 -0800 Subject: [PATCH 0031/1058] Use strict typing for LiteJet integration (#88629) * Strict typing for LiteJet. * Add test for new check. * PR feedback. * PR feedback. --- .strict-typing | 1 + homeassistant/components/litejet/__init__.py | 4 ++-- homeassistant/components/litejet/config_flow.py | 2 +- homeassistant/components/litejet/trigger.py | 16 ++++++++++------ mypy.ini | 10 ++++++++++ tests/components/litejet/test_trigger.py | 11 +++++++++++ 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.strict-typing b/.strict-typing index 13fd49391e94..b33eab5bd65d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -186,6 +186,7 @@ homeassistant.components.ld2410_ble.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* +homeassistant.components.litejet.* homeassistant.components.litterrobot.* homeassistant.components.local_ip.* homeassistant.components.lock.* diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 040b8688a429..291333d0b74f 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: system.on_connected_changed(handle_connected_changed) - async def handle_stop(event) -> None: + async def handle_stop(event: Event) -> None: await system.close() entry.async_on_unload( diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index 25d454071cc6..c469d480ca6f 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -76,7 +76,7 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data): + async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: """Import litejet config from configuration.yaml.""" return self.async_create_entry(title=import_data[CONF_PORT], data=import_data) diff --git a/homeassistant/components/litejet/trigger.py b/homeassistant/components/litejet/trigger.py index 926ed69637f2..df5ffac9b99b 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections.abc import Callable +from datetime import datetime +from typing import cast from pylitejet import LiteJet import voluptuous as vol @@ -42,7 +44,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for events based on configuration.""" trigger_data = trigger_info["trigger_data"] - number = config.get(CONF_NUMBER) + number = cast(int, config[CONF_NUMBER]) held_more_than = config.get(CONF_HELD_MORE_THAN) held_less_than = config.get(CONF_HELD_LESS_THAN) pressed_time = None @@ -50,7 +52,7 @@ async def async_attach_trigger( job = HassJob(action) @callback - def call_action(): + def call_action() -> None: """Call action with right context.""" hass.async_run_hass_job( job, @@ -72,11 +74,11 @@ async def async_attach_trigger( # neither: trigger on pressed @callback - def pressed_more_than_satisfied(now): + def pressed_more_than_satisfied(now: datetime) -> None: """Handle the LiteJet's switch's button pressed >= held_more_than.""" call_action() - def pressed(): + def pressed() -> None: """Handle the press of the LiteJet switch's button.""" nonlocal cancel_pressed_more_than, pressed_time nonlocal held_less_than, held_more_than @@ -88,10 +90,12 @@ async def async_attach_trigger( hass, pressed_more_than_satisfied, dt_util.utcnow() + held_more_than ) - def released(): + def released() -> None: """Handle the release of the LiteJet switch's button.""" nonlocal cancel_pressed_more_than, pressed_time nonlocal held_less_than, held_more_than + if pressed_time is None: + return if cancel_pressed_more_than is not None: cancel_pressed_more_than() cancel_pressed_more_than = None @@ -110,7 +114,7 @@ async def async_attach_trigger( system.on_switch_released(number, released) @callback - def async_remove(): + def async_remove() -> None: """Remove all subscriptions used for this trigger.""" system.unsubscribe(pressed) system.unsubscribe(released) diff --git a/mypy.ini b/mypy.ini index db5df3a5b4bb..6d32c16b96b9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1622,6 +1622,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.litejet.*] +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.litterrobot.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index 41c4af23c999..40511c3f45ba 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -107,6 +107,17 @@ async def test_simple(hass: HomeAssistant, calls, mock_litejet) -> None: assert calls[0].data["id"] == 0 +async def test_only_release(hass: HomeAssistant, calls, mock_litejet) -> None: + """Test the simplest form of a LiteJet trigger.""" + await setup_automation( + hass, {"platform": "litejet", "number": ENTITY_OTHER_SWITCH_NUMBER} + ) + + await simulate_release(hass, mock_litejet, ENTITY_OTHER_SWITCH_NUMBER) + + assert len(calls) == 0 + + async def test_held_more_than_short(hass: HomeAssistant, calls, mock_litejet) -> None: """Test a too short hold.""" await setup_automation( From 69a3738bdb984a0d564d3846e0ec319074408032 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 11:41:44 -0600 Subject: [PATCH 0032/1058] Fix migration failing when existing data has duplicates (#88712) --- .../components/recorder/migration.py | 29 ++++++++++++++++--- homeassistant/components/recorder/util.py | 2 +- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index a3a609a1b6f9..431bc78ba801 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -13,6 +13,7 @@ from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text from sqlalchemy.engine import CursorResult, Engine from sqlalchemy.exc import ( DatabaseError, + IntegrityError, InternalError, OperationalError, ProgrammingError, @@ -778,9 +779,10 @@ def _apply_update( # noqa: C901 # Add name column to StatisticsMeta _add_columns(session_maker, "statistics_meta", ["name VARCHAR(255)"]) elif new_version == 24: - _LOGGER.debug("Deleting duplicated statistics entries") - with session_scope(session=session_maker()) as session: - delete_statistics_duplicates(hass, session) + # This used to create the unique indices for start and statistic_id + # but we changed the format in schema 34 which will now take care + # of removing any duplicate if they still exist. + pass elif new_version == 25: _add_columns(session_maker, "states", [f"attributes_id {big_int}"]) _create_index(session_maker, "states", "ix_states_attributes_id") @@ -907,7 +909,26 @@ def _apply_update( # noqa: C901 "statistics_short_term", "ix_statistics_short_term_statistic_id_start_ts", ) - _migrate_statistics_columns_to_timestamp(session_maker, engine) + try: + _migrate_statistics_columns_to_timestamp(session_maker, engine) + except IntegrityError as ex: + _LOGGER.error( + "Statistics table contains duplicate entries: %s; " + "Cleaning up duplicates and trying again; " + "This will take a while; " + "Please be patient!", + ex, + ) + # There may be duplicated statistics entries, delete duplicates + # and try again + with session_scope(session=session_maker()) as session: + delete_statistics_duplicates(hass, session) + _migrate_statistics_columns_to_timestamp(session_maker, engine) + # Log at error level to ensure the user sees this message in the log + # since we logged the error above. + _LOGGER.error( + "Statistics migration successfully recovered after statistics table duplicate cleanup" + ) elif new_version == 35: # Migration is done in two steps to ensure we can start using # the new columns before we wipe the old ones. diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 5cda3d283dd1..3ff6b62b21e5 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -125,7 +125,7 @@ def session_scope( need_rollback = True session.commit() except Exception as err: # pylint: disable=broad-except - _LOGGER.error("Error executing query: %s", err) + _LOGGER.error("Error executing query: %s", err, exc_info=True) if need_rollback: session.rollback() if not exception_filter or not exception_filter(err): From 7b2e743a6b0ce8dce5595eb54d089c3142cc6d94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 13:33:25 -0600 Subject: [PATCH 0033/1058] Fix timeout in purpleapi test (#88715) https://github.com/home-assistant/core/actions/runs/4264644494/jobs/7423099757 --- tests/components/purpleair/conftest.py | 2 +- .../components/purpleair/test_config_flow.py | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/components/purpleair/conftest.py b/tests/components/purpleair/conftest.py index 85598815c2c6..ef48a5988a37 100644 --- a/tests/components/purpleair/conftest.py +++ b/tests/components/purpleair/conftest.py @@ -75,7 +75,7 @@ async def mock_aiopurpleair_fixture(api): with patch( "homeassistant.components.purpleair.config_flow.API", return_value=api ), patch("homeassistant.components.purpleair.coordinator.API", return_value=api): - yield + yield api @pytest.fixture(name="setup_config_entry") diff --git a/tests/components/purpleair/test_config_flow.py b/tests/components/purpleair/test_config_flow.py index 08768e8bacce..ce911183dfd9 100644 --- a/tests/components/purpleair/test_config_flow.py +++ b/tests/components/purpleair/test_config_flow.py @@ -123,7 +123,7 @@ async def test_duplicate_error( ) async def test_reauth( hass: HomeAssistant, - api, + mock_aiopurpleair, check_api_key_errors, check_api_key_mock, config_entry, @@ -143,7 +143,7 @@ async def test_reauth( assert result["step_id"] == "reauth_confirm" # Test errors that can arise when checking the API key: - with patch.object(api, "async_check_api_key", check_api_key_mock): + with patch.object(mock_aiopurpleair, "async_check_api_key", check_api_key_mock): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"api_key": "new_api_key"} ) @@ -157,6 +157,9 @@ async def test_reauth( assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 + # Unload to make sure the update does not run after the + # mock is removed. + await hass.config_entries.async_unload(config_entry.entry_id) @pytest.mark.parametrize( @@ -169,7 +172,7 @@ async def test_reauth( ) async def test_options_add_sensor( hass: HomeAssistant, - api, + mock_aiopurpleair, config_entry, get_nearby_sensors_errors, get_nearby_sensors_mock, @@ -187,7 +190,9 @@ async def test_options_add_sensor( assert result["step_id"] == "add_sensor" # Test errors that can arise when searching for nearby sensors: - with patch.object(api.sensors, "async_get_nearby_sensors", get_nearby_sensors_mock): + with patch.object( + mock_aiopurpleair.sensors, "async_get_nearby_sensors", get_nearby_sensors_mock + ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ @@ -225,6 +230,9 @@ async def test_options_add_sensor( TEST_SENSOR_INDEX1, TEST_SENSOR_INDEX2, ] + # Unload to make sure the update does not run after the + # mock is removed. + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_add_sensor_duplicate( @@ -260,6 +268,9 @@ async def test_options_add_sensor_duplicate( ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" + # Unload to make sure the update does not run after the + # mock is removed. + await hass.config_entries.async_unload(config_entry.entry_id) async def test_options_remove_sensor( @@ -288,3 +299,6 @@ async def test_options_remove_sensor( } assert config_entry.options["sensor_indices"] == [] + # Unload to make sure the update does not run after the + # mock is removed. + await hass.config_entries.async_unload(config_entry.entry_id) From 0223058d25815abf634b20b9f95a8b66044227a7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 20:37:36 -0600 Subject: [PATCH 0034/1058] Reduce overhead to save json data to postgresql (#88717) * Reduce overhead to strip nulls from json * Reduce overhead to strip nulls from json * small cleanup --- homeassistant/helpers/json.py | 42 +++++++++++++---------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 38afa37838a5..c15436ed2c1e 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -83,38 +83,28 @@ def json_bytes(data: Any) -> bytes: ) +def _strip_null(obj: Any) -> Any: + """Strip NUL from an object.""" + if isinstance(obj, str): + return obj.split("\0", 1)[0] + if isinstance(obj, dict): + return {key: _strip_null(o) for key, o in obj.items()} + if isinstance(obj, list): + return [_strip_null(o) for o in obj] + return obj + + def json_bytes_strip_null(data: Any) -> bytes: """Dump json bytes after terminating strings at the first NUL.""" - - def process_dict(_dict: dict[Any, Any]) -> dict[Any, Any]: - """Strip NUL from items in a dict.""" - return {key: strip_null(o) for key, o in _dict.items()} - - def process_list(_list: list[Any]) -> list[Any]: - """Strip NUL from items in a list.""" - return [strip_null(o) for o in _list] - - def strip_null(obj: Any) -> Any: - """Strip NUL from an object.""" - if isinstance(obj, str): - return obj.split("\0", 1)[0] - if isinstance(obj, dict): - return process_dict(obj) - if isinstance(obj, list): - return process_list(obj) - return obj - # We expect null-characters to be very rare, hence try encoding first and look # for an escaped null-character in the output. result = json_bytes(data) - if b"\\u0000" in result: - # We work on the processed result so we don't need to worry about - # Home Assistant extensions which allows encoding sets, tuples, etc. - data_processed = orjson.loads(result) - data_processed = strip_null(data_processed) - result = json_bytes(data_processed) + if b"\\u0000" not in result: + return result - return result + # We work on the processed result so we don't need to worry about + # Home Assistant extensions which allows encoding sets, tuples, etc. + return json_bytes(_strip_null(orjson.loads(result))) def json_dumps(data: Any) -> str: From a60fd18386936ca72434a90679f20528c0a0b0dd Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 25 Feb 2023 03:39:59 +0100 Subject: [PATCH 0035/1058] Update frontend to 20230224.0 (#88721) --- 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 a5930177b9cd..1daffd430762 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==20230223.0"] + "requirements": ["home-assistant-frontend==20230224.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4675a2ae9237..08cccaf7b5f8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.5 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230223.0 +home-assistant-frontend==20230224.0 home-assistant-intents==2023.2.22 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 032305ac4656..a3f23fa171e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230223.0 +home-assistant-frontend==20230224.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 867d89c6b7eb..454ae7bbd92d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230223.0 +home-assistant-frontend==20230224.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 From 5a365788b5b118c98003265de95192ad1304e3d7 Mon Sep 17 00:00:00 2001 From: mkmer Date: Fri, 24 Feb 2023 21:49:49 -0500 Subject: [PATCH 0036/1058] Add missing reauth strings to Honeywell (#88733) Add missing reauth strings --- homeassistant/components/honeywell/strings.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 87f3e0259170..73986920b8a3 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -7,6 +7,13 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Honeywell integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { From 1edef73c9aa996fe3b45ac0a4ac3723f8c9893ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 25 Feb 2023 04:10:00 +0100 Subject: [PATCH 0037/1058] Update aioqsw v0.3.2 (#88695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/qnap_qsw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json index e2f188541d76..178251104905 100644 --- a/homeassistant/components/qnap_qsw/manifest.json +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/qnap_qsw", "iot_class": "local_polling", "loggers": ["aioqsw"], - "requirements": ["aioqsw==0.3.1"] + "requirements": ["aioqsw==0.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3f23fa171e9..1a8ecd655f98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -249,7 +249,7 @@ aiopvpc==4.0.1 aiopyarr==22.11.0 # homeassistant.components.qnap_qsw -aioqsw==0.3.1 +aioqsw==0.3.2 # homeassistant.components.recollect_waste aiorecollect==1.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 454ae7bbd92d..35415529dd15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ aiopvpc==4.0.1 aiopyarr==22.11.0 # homeassistant.components.qnap_qsw -aioqsw==0.3.1 +aioqsw==0.3.2 # homeassistant.components.recollect_waste aiorecollect==1.0.8 From f52a5f696500d384b33987fae46ccd35885f2913 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 22:11:48 -0600 Subject: [PATCH 0038/1058] Make hass.async_stop an untracked task (#88738) --- homeassistant/helpers/signal.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index 9fd643a77574..c7035d5a0d2a 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -1,4 +1,5 @@ """Signal handling related helpers.""" +import asyncio import logging import signal @@ -23,7 +24,9 @@ def async_register_signal_handling(hass: HomeAssistant) -> None: """ hass.loop.remove_signal_handler(signal.SIGTERM) hass.loop.remove_signal_handler(signal.SIGINT) - hass.async_create_task(hass.async_stop(exit_code)) + hass.data["homeassistant_stop"] = asyncio.create_task( + hass.async_stop(exit_code) + ) try: hass.loop.add_signal_handler(signal.SIGTERM, async_signal_handle, 0) From f18c0bf6268119bbd580175a6b377658c739121c Mon Sep 17 00:00:00 2001 From: Artem Draft Date: Sat, 25 Feb 2023 09:43:58 +0300 Subject: [PATCH 0039/1058] Pass `assumed_state` property in universal media player (#87846) Pass assumed_state property in universal media player --- homeassistant/components/universal/media_player.py | 6 ++++++ tests/components/universal/test_media_player.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 2cbe7aa6fb14..9f6c9db416e4 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -47,6 +47,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, @@ -291,6 +292,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): """Return the name of universal player.""" return self._name + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self._child_attr(ATTR_ASSUMED_STATE) + @property def state(self): """Return the current state of media player. diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 81204fe21c2b..fd8c572685cb 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -492,6 +492,13 @@ async def test_state_children_only(hass: HomeAssistant, mock_states) -> None: await ump.async_update() assert ump.state == STATE_PLAYING + mock_states.mock_mp_1._state = STATE_ON + mock_states.mock_mp_1._attr_assumed_state = True + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert ump.assumed_state is True + async def test_state_with_children_and_attrs( hass: HomeAssistant, config_children_and_attr, mock_states From 3499d60401a429b2bef148ff59c300ef9757fc73 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sat, 25 Feb 2023 00:20:17 -0800 Subject: [PATCH 0040/1058] Bump total_connect_client to v2023.2 (#88729) * bump total_connect_client to v2023.2 * Trigger Build --- homeassistant/components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index a820a7a034a4..8e0d58b7b778 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total_connect_client==2023.1"] + "requirements": ["total_connect_client==2023.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a8ecd655f98..9fae25122a4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2518,7 +2518,7 @@ tololib==0.1.0b4 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2023.1 +total_connect_client==2023.2 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35415529dd15..22c4b7e4dff9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1773,7 +1773,7 @@ tololib==0.1.0b4 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2023.1 +total_connect_client==2023.2 # homeassistant.components.tplink_omada tplink-omada-client==1.1.0 From 091305fc57067f4641c83ceeb2f570453cde72a1 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sat, 25 Feb 2023 11:05:51 +0200 Subject: [PATCH 0041/1058] Use DataUpdateCoordinator for islamic_prayer_times (#73893) * use DataUpdateCoordinator for islamic_prayer_times Add suggested type hints remove uneccassry options setup * Use entity_description for sensors * move coordinator into separate file, sensor_descptions to sensor.py * add strict typing * revert strict typing * fix test coverage * revert unrelated file changes * fix sorting * Update code based on review * add missing type hint * more missing type hints * Update homeassistant/components/islamic_prayer_times/coordinator.py Co-authored-by: Martin Hjelmare * remove config_entry parameter --------- Co-authored-by: Martin Hjelmare --- .../islamic_prayer_times/__init__.py | 179 +++--------------- .../islamic_prayer_times/config_flow.py | 11 +- .../components/islamic_prayer_times/const.py | 23 +-- .../islamic_prayer_times/coordinator.py | 121 ++++++++++++ .../components/islamic_prayer_times/sensor.py | 103 ++++++---- .../islamic_prayer_times/__init__.py | 28 +-- .../islamic_prayer_times/test_config_flow.py | 32 ++-- .../islamic_prayer_times/test_init.py | 20 +- .../islamic_prayer_times/test_sensor.py | 18 +- 9 files changed, 272 insertions(+), 263 deletions(-) create mode 100644 homeassistant/components/islamic_prayer_times/coordinator.py diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 7fd5ed4129fa..95a7db632b11 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -1,22 +1,13 @@ """The islamic_prayer_times component.""" -from datetime import timedelta -import logging - -from prayer_times_calculator import PrayerTimesCalculator, exceptions -from requests.exceptions import ConnectionError as ConnError +from __future__ import annotations 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.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_call_later, async_track_point_in_time -import homeassistant.util.dt as dt_util -from .const import CONF_CALC_METHOD, DATA_UPDATED, DEFAULT_CALC_METHOD, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import IslamicPrayerDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -25,154 +16,32 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" - client = IslamicPrayerClient(hass, config_entry) - hass.data[DOMAIN] = client - await client.async_setup() + coordinator = IslamicPrayerDataUpdateCoordinator(hass) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, coordinator) + config_entry.async_on_unload( + config_entry.add_update_listener(async_options_updated) + ) + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Islamic Prayer entry from config_entry.""" - if hass.data[DOMAIN].event_unsub: - hass.data[DOMAIN].event_unsub() - hass.data.pop(DOMAIN) - return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data.pop(DOMAIN) + if coordinator.event_unsub: + coordinator.event_unsub() + return unload_ok -class IslamicPrayerClient: - """Islamic Prayer Client Object.""" - - def __init__(self, hass, config_entry): - """Initialize the Islamic Prayer client.""" - self.hass = hass - self.config_entry = config_entry - self.prayer_times_info = {} - self.available = True - self.event_unsub = None - - @property - def calc_method(self): - """Return the calculation method.""" - return self.config_entry.options[CONF_CALC_METHOD] - - def get_new_prayer_times(self): - """Fetch prayer times for today.""" - calc = PrayerTimesCalculator( - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, - calculation_method=self.calc_method, - date=str(dt_util.now().date()), - ) - return calc.fetch_prayer_times() - - async def async_schedule_future_update(self): - """Schedule future update for sensors. - - Midnight is a calculated time. The specifics of the calculation - depends on the method of the prayer time calculation. This calculated - midnight is the time at which the time to pray the Isha prayers have - expired. - - Calculated Midnight: The Islamic midnight. - Traditional Midnight: 12:00AM - - Update logic for prayer times: - - If the Calculated Midnight is before the traditional midnight then wait - until the traditional midnight to run the update. This way the day - will have changed over and we don't need to do any fancy calculations. - - If the Calculated Midnight is after the traditional midnight, then wait - until after the calculated Midnight. We don't want to update the prayer - times too early or else the timings might be incorrect. - - Example: - calculated midnight = 11:23PM (before traditional midnight) - Update time: 12:00AM - - calculated midnight = 1:35AM (after traditional midnight) - update time: 1:36AM. - - """ - _LOGGER.debug("Scheduling next update for Islamic prayer times") - - now = dt_util.utcnow() - - midnight_dt = self.prayer_times_info["Midnight"] - - if now > dt_util.as_utc(midnight_dt): - next_update_at = midnight_dt + timedelta(days=1, minutes=1) - _LOGGER.debug( - "Midnight is after day the changes so schedule update for after" - " Midnight the next day" - ) - else: - _LOGGER.debug( - "Midnight is before the day changes so schedule update for the next" - " start of day" - ) - next_update_at = dt_util.start_of_local_day(now + timedelta(days=1)) - - _LOGGER.info("Next update scheduled for: %s", next_update_at) - - self.event_unsub = async_track_point_in_time( - self.hass, self.async_update, next_update_at - ) - - async def async_update(self, *_): - """Update sensors with new prayer times.""" - try: - prayer_times = await self.hass.async_add_executor_job( - self.get_new_prayer_times - ) - self.available = True - except (exceptions.InvalidResponseError, ConnError): - self.available = False - _LOGGER.debug("Error retrieving prayer times") - async_call_later(self.hass, 60, self.async_update) - return - - for prayer, time in prayer_times.items(): - self.prayer_times_info[prayer] = dt_util.parse_datetime( - f"{dt_util.now().date()} {time}" - ) - await self.async_schedule_future_update() - - _LOGGER.debug("New prayer times retrieved. Updating sensors") - async_dispatcher_send(self.hass, DATA_UPDATED) - - async def async_setup(self): - """Set up the Islamic prayer client.""" - await self.async_add_options() - - try: - await self.hass.async_add_executor_job(self.get_new_prayer_times) - except (exceptions.InvalidResponseError, ConnError) as err: - raise ConfigEntryNotReady from err - - await self.async_update() - self.config_entry.add_update_listener(self.async_options_updated) - - await self.hass.config_entries.async_forward_entry_setups( - self.config_entry, PLATFORMS - ) - - return True - - async def async_add_options(self): - """Add options for entry.""" - if not self.config_entry.options: - data = dict(self.config_entry.data) - calc_method = data.pop(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) - - self.hass.config_entries.async_update_entry( - self.config_entry, data=data, options={CONF_CALC_METHOD: calc_method} - ) - - @staticmethod - async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Triggered by config entry options updates.""" - if hass.data[DOMAIN].event_unsub: - hass.data[DOMAIN].event_unsub() - await hass.data[DOMAIN].async_update() +async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Triggered by config entry options updates.""" + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] + if coordinator.event_unsub: + coordinator.event_unsub() + await coordinator.async_request_refresh() diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 5278750d36e3..d0d314fe67d2 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -1,10 +1,13 @@ """Config flow for Islamic Prayer Times integration.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME @@ -22,7 +25,9 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return IslamicPrayerOptionsFlowHandler(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 a flow initialized by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -40,7 +45,9 @@ class IslamicPrayerOptionsFlowHandler(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: """Manage options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index e037f486aaa3..2a73a33bef80 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -1,23 +1,12 @@ """Constants for the Islamic Prayer component.""" +from typing import Final + from prayer_times_calculator import PrayerTimesCalculator -DOMAIN = "islamic_prayer_times" -NAME = "Islamic Prayer Times" -PRAYER_TIMES_ICON = "mdi:calendar-clock" +DOMAIN: Final = "islamic_prayer_times" +NAME: Final = "Islamic Prayer Times" -SENSOR_TYPES = { - "Fajr": "prayer", - "Sunrise": "time", - "Dhuhr": "prayer", - "Asr": "prayer", - "Maghrib": "prayer", - "Isha": "prayer", - "Midnight": "time", -} - -CONF_CALC_METHOD = "calculation_method" +CONF_CALC_METHOD: Final = "calculation_method" CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS) -DEFAULT_CALC_METHOD = "isna" - -DATA_UPDATED = "Islamic_prayer_data_updated" +DEFAULT_CALC_METHOD: Final = "isna" diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py new file mode 100644 index 000000000000..1a8b0bf70364 --- /dev/null +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -0,0 +1,121 @@ +"""Coordinator for the Islamic prayer times integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from prayer_times_calculator import PrayerTimesCalculator, exceptions +from requests.exceptions import ConnectionError as ConnError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_call_later, async_track_point_in_time +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetime]]): + """Islamic Prayer Client Object.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the Islamic Prayer client.""" + self.event_unsub: CALLBACK_TYPE | None = None + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + ) + + @property + def calc_method(self) -> str: + """Return the calculation method.""" + return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) + + def get_new_prayer_times(self) -> dict[str, str]: + """Fetch prayer times for today.""" + calc = PrayerTimesCalculator( + latitude=self.hass.config.latitude, + longitude=self.hass.config.longitude, + calculation_method=self.calc_method, + date=str(dt_util.now().date()), + ) + return calc.fetch_prayer_times() + + @callback + def async_schedule_future_update(self, midnight_dt: datetime) -> None: + """Schedule future update for sensors. + + Midnight is a calculated time. The specifics of the calculation + depends on the method of the prayer time calculation. This calculated + midnight is the time at which the time to pray the Isha prayers have + expired. + + Calculated Midnight: The Islamic midnight. + Traditional Midnight: 12:00AM + + Update logic for prayer times: + + If the Calculated Midnight is before the traditional midnight then wait + until the traditional midnight to run the update. This way the day + will have changed over and we don't need to do any fancy calculations. + + If the Calculated Midnight is after the traditional midnight, then wait + until after the calculated Midnight. We don't want to update the prayer + times too early or else the timings might be incorrect. + + Example: + calculated midnight = 11:23PM (before traditional midnight) + Update time: 12:00AM + + calculated midnight = 1:35AM (after traditional midnight) + update time: 1:36AM. + + """ + _LOGGER.debug("Scheduling next update for Islamic prayer times") + + now = dt_util.utcnow() + + if now > midnight_dt: + next_update_at = midnight_dt + timedelta(days=1, minutes=1) + _LOGGER.debug( + "Midnight is after the day changes so schedule update for after Midnight the next day" + ) + else: + _LOGGER.debug( + "Midnight is before the day changes so schedule update for the next start of day" + ) + next_update_at = dt_util.start_of_local_day(now + timedelta(days=1)) + + _LOGGER.debug("Next update scheduled for: %s", next_update_at) + + self.event_unsub = async_track_point_in_time( + self.hass, self.async_request_update, next_update_at + ) + + async def async_request_update(self, *_) -> None: + """Request update from coordinator.""" + await self.async_request_refresh() + + async def _async_update_data(self) -> dict[str, datetime]: + """Update sensors with new prayer times.""" + try: + prayer_times = await self.hass.async_add_executor_job( + self.get_new_prayer_times + ) + except (exceptions.InvalidResponseError, ConnError) as err: + async_call_later(self.hass, 60, self.async_request_update) + raise UpdateFailed from err + + prayer_times_info: dict[str, datetime] = {} + for prayer, time in prayer_times.items(): + if prayer_time := dt_util.parse_datetime(f"{dt_util.now().date()} {time}"): + prayer_times_info[prayer] = dt_util.as_utc(prayer_time) + + self.async_schedule_future_update(prayer_times_info["Midnight"]) + return prayer_times_info diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index a90a2c53c528..abaefec40824 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -1,12 +1,51 @@ """Platform to retrieve Islamic prayer times information for Home Assistant.""" -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_UPDATED, DOMAIN, PRAYER_TIMES_ICON, SENSOR_TYPES +from . import IslamicPrayerDataUpdateCoordinator +from .const import DOMAIN, NAME + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="Fajr", + name="Fajr prayer", + ), + SensorEntityDescription( + key="Sunrise", + name="Sunrise time", + ), + SensorEntityDescription( + key="Dhuhr", + name="Dhuhr prayer", + ), + SensorEntityDescription( + key="Asr", + name="Asr prayer", + ), + SensorEntityDescription( + key="Maghrib", + name="Maghrib prayer", + ), + SensorEntityDescription( + key="Isha", + name="Isha prayer", + ), + SensorEntityDescription( + key="Midnight", + name="Midnight time", + ), +) async def async_setup_entry( @@ -16,46 +55,38 @@ async def async_setup_entry( ) -> None: """Set up the Islamic prayer times sensor platform.""" - client = hass.data[DOMAIN] + coordinator: IslamicPrayerDataUpdateCoordinator = hass.data[DOMAIN] - entities = [] - for sensor_type in SENSOR_TYPES: - entities.append(IslamicPrayerTimeSensor(sensor_type, client)) - - async_add_entities(entities, True) + async_add_entities( + IslamicPrayerTimeSensor(coordinator, description) + for description in SENSOR_TYPES + ) -class IslamicPrayerTimeSensor(SensorEntity): +class IslamicPrayerTimeSensor( + CoordinatorEntity[IslamicPrayerDataUpdateCoordinator], SensorEntity +): """Representation of an Islamic prayer time sensor.""" _attr_device_class = SensorDeviceClass.TIMESTAMP - _attr_icon = PRAYER_TIMES_ICON - _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, sensor_type, client): + def __init__( + self, + coordinator: IslamicPrayerDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize the Islamic prayer time sensor.""" - self.sensor_type = sensor_type - self.client = client + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = description.key + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name=NAME, + entry_type=DeviceEntryType.SERVICE, + ) @property - def name(self): - """Return the name of the sensor.""" - return f"{self.sensor_type} {SENSOR_TYPES[self.sensor_type]}" - - @property - def unique_id(self): - """Return the unique id of the entity.""" - return self.sensor_type - - @property - def native_value(self): + def native_value(self) -> datetime: """Return the state of the sensor.""" - return self.client.prayer_times_info.get(self.sensor_type).astimezone( - dt_util.UTC - ) - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, DATA_UPDATED, self.async_write_ha_state) - ) + return self.coordinator.data[self.entity_description.key] diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 3afc2207ffa2..386d20ab98e2 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -15,13 +15,13 @@ PRAYER_TIMES = { } PRAYER_TIMES_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 1, 6, 10, 0), - "Sunrise": datetime(2020, 1, 1, 7, 25, 0), - "Dhuhr": datetime(2020, 1, 1, 12, 30, 0), - "Asr": datetime(2020, 1, 1, 15, 32, 0), - "Maghrib": datetime(2020, 1, 1, 17, 35, 0), - "Isha": datetime(2020, 1, 1, 18, 53, 0), - "Midnight": datetime(2020, 1, 1, 00, 45, 0), + "Fajr": datetime(2020, 1, 1, 6, 10, 0, tzinfo=dt_util.UTC), + "Sunrise": datetime(2020, 1, 1, 7, 25, 0, tzinfo=dt_util.UTC), + "Dhuhr": datetime(2020, 1, 1, 12, 30, 0, tzinfo=dt_util.UTC), + "Asr": datetime(2020, 1, 1, 15, 32, 0, tzinfo=dt_util.UTC), + "Maghrib": datetime(2020, 1, 1, 17, 35, 0, tzinfo=dt_util.UTC), + "Isha": datetime(2020, 1, 1, 18, 53, 0, tzinfo=dt_util.UTC), + "Midnight": datetime(2020, 1, 1, 00, 45, 0, tzinfo=dt_util.UTC), } NEW_PRAYER_TIMES = { @@ -35,13 +35,13 @@ NEW_PRAYER_TIMES = { } NEW_PRAYER_TIMES_TIMESTAMPS = { - "Fajr": datetime(2020, 1, 1, 6, 00, 0), - "Sunrise": datetime(2020, 1, 1, 7, 25, 0), - "Dhuhr": datetime(2020, 1, 1, 12, 30, 0), - "Asr": datetime(2020, 1, 1, 15, 32, 0), - "Maghrib": datetime(2020, 1, 1, 17, 45, 0), - "Isha": datetime(2020, 1, 1, 18, 53, 0), - "Midnight": datetime(2020, 1, 1, 00, 43, 0), + "Fajr": datetime(2020, 1, 1, 6, 00, 0, tzinfo=dt_util.UTC), + "Sunrise": datetime(2020, 1, 1, 7, 25, 0, tzinfo=dt_util.UTC), + "Dhuhr": datetime(2020, 1, 1, 12, 30, 0, tzinfo=dt_util.UTC), + "Asr": datetime(2020, 1, 1, 15, 32, 0, tzinfo=dt_util.UTC), + "Maghrib": datetime(2020, 1, 1, 17, 45, 0, tzinfo=dt_util.UTC), + "Isha": datetime(2020, 1, 1, 18, 53, 0, tzinfo=dt_util.UTC), + "Midnight": datetime(2020, 1, 1, 00, 43, 0, tzinfo=dt_util.UTC), } NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index 947e5002ea94..664309387343 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,27 +1,16 @@ """Tests for Islamic Prayer Times config flow.""" from unittest.mock import patch -import pytest - from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times -from homeassistant.components.islamic_prayer_times import config_flow # noqa: F401 from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD, DOMAIN from homeassistant.core import HomeAssistant +from . import PRAYER_TIMES + from tests.common import MockConfigEntry -@pytest.fixture(name="mock_setup", autouse=True) -def mock_setup(): - """Mock entry setup.""" - with patch( - "homeassistant.components.islamic_prayer_times.async_setup_entry", - return_value=True, - ): - yield - - async def test_flow_works(hass: HomeAssistant) -> None: """Test user config.""" result = await hass.config_entries.flow.async_init( @@ -30,9 +19,13 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + with patch( + "homeassistant.components.islamic_prayer_times.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Islamic Prayer Times" @@ -47,6 +40,13 @@ async def test_options(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index b46f2740c367..d641a22590d9 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -22,7 +22,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(autouse=True) -def set_utc(hass): +def set_utc(hass: HomeAssistant) -> None: """Set timezone to UTC.""" hass.config.set_time_zone("UTC") @@ -44,9 +44,6 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.LOADED - assert entry.options == { - islamic_prayer_times.CONF_CALC_METHOD: islamic_prayer_times.DEFAULT_CALC_METHOD - } async def test_setup_failed(hass: HomeAssistant) -> None: @@ -100,10 +97,7 @@ async def test_islamic_prayer_times_timestamp_format(hass: HomeAssistant) -> Non await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert ( - hass.data[islamic_prayer_times.DOMAIN].prayer_times_info - == PRAYER_TIMES_TIMESTAMPS - ) + assert hass.data[islamic_prayer_times.DOMAIN].data == PRAYER_TIMES_TIMESTAMPS async def test_update(hass: HomeAssistant) -> None: @@ -115,7 +109,6 @@ async def test_update(hass: HomeAssistant) -> None: "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times" ) as FetchPrayerTimes, freeze_time(NOW): FetchPrayerTimes.side_effect = [ - PRAYER_TIMES, PRAYER_TIMES, NEW_PRAYER_TIMES, ] @@ -124,13 +117,10 @@ async def test_update(hass: HomeAssistant) -> None: await hass.async_block_till_done() pt_data = hass.data[islamic_prayer_times.DOMAIN] - assert pt_data.prayer_times_info == PRAYER_TIMES_TIMESTAMPS + assert pt_data.data == PRAYER_TIMES_TIMESTAMPS - future = pt_data.prayer_times_info["Midnight"] + timedelta(days=1, minutes=1) + future = pt_data.data["Midnight"] + timedelta(days=1, minutes=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() - assert ( - hass.data[islamic_prayer_times.DOMAIN].prayer_times_info - == NEW_PRAYER_TIMES_TIMESTAMPS - ) + assert pt_data.data == NEW_PRAYER_TIMES_TIMESTAMPS diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 56dd0e3805f7..3b291c9973d6 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -4,8 +4,10 @@ from unittest.mock import patch from freezegun import freeze_time import pytest -from homeassistant.components import islamic_prayer_times +from homeassistant.components.islamic_prayer_times.const import DOMAIN +from homeassistant.components.islamic_prayer_times.sensor import SENSOR_TYPES from homeassistant.core import HomeAssistant +from homeassistant.util import slugify import homeassistant.util.dt as dt_util from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS @@ -14,14 +16,14 @@ from tests.common import MockConfigEntry @pytest.fixture(autouse=True) -def set_utc(hass): +def set_utc(hass: HomeAssistant) -> None: """Set timezone to UTC.""" hass.config.set_time_zone("UTC") async def test_islamic_prayer_times_sensors(hass: HomeAssistant) -> None: """Test minimum Islamic prayer times configuration.""" - entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry = MockConfigEntry(domain=DOMAIN, data={}) entry.add_to_hass(hass) with patch( @@ -31,10 +33,10 @@ async def test_islamic_prayer_times_sensors(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - for prayer in PRAYER_TIMES: + for prayer in SENSOR_TYPES: assert ( - hass.states.get( - f"sensor.{prayer}_{islamic_prayer_times.const.SENSOR_TYPES[prayer]}" - ).state - == PRAYER_TIMES_TIMESTAMPS[prayer].astimezone(dt_util.UTC).isoformat() + hass.states.get(f"sensor.{DOMAIN}_{slugify(prayer.name)}").state + == PRAYER_TIMES_TIMESTAMPS[prayer.key] + .astimezone(dt_util.UTC) + .isoformat() ) From 0a3a8c4b3c823a753fb2db82efcb6c4b1b55b71a Mon Sep 17 00:00:00 2001 From: Arturo Date: Sat, 25 Feb 2023 03:25:04 -0600 Subject: [PATCH 0042/1058] Fix matter light color capabilities bit map (#88693) * Adds matter light color capabilities bit map * Fixed matter light hue and saturation test --- homeassistant/components/matter/light.py | 61 +++++++++++++----------- tests/components/matter/test_light.py | 8 ++-- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index da0739cd4179..080cc472f2db 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -1,6 +1,7 @@ """Matter light.""" from __future__ import annotations +from enum import IntFlag from typing import Any from chip.clusters import Objects as clusters @@ -260,12 +261,16 @@ class MatterLight(MatterEntity, LightEntity): color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) - if hs_color is not None and self.supports_color: - await self._set_hs_color(hs_color) - elif xy_color is not None: - await self._set_xy_color(xy_color) - elif color_temp is not None and self.supports_color_temperature: - await self._set_color_temp(color_temp) + if self.supported_color_modes is not None: + if hs_color is not None and ColorMode.HS in self.supported_color_modes: + await self._set_hs_color(hs_color) + elif xy_color is not None and ColorMode.XY in self.supported_color_modes: + await self._set_xy_color(xy_color) + elif ( + color_temp is not None + and ColorMode.COLOR_TEMP in self.supported_color_modes + ): + await self._set_color_temp(color_temp) if brightness is not None and self.supports_brightness: await self._set_brightness(brightness) @@ -284,7 +289,6 @@ class MatterLight(MatterEntity, LightEntity): @callback def _update_from_device(self) -> None: """Update from device.""" - if self._attr_supported_color_modes is None: # work out what (color)features are supported supported_color_modes: set[ColorMode] = set() @@ -297,30 +301,19 @@ class MatterLight(MatterEntity, LightEntity): if self._entity_info.endpoint.has_attribute( None, clusters.ColorControl.Attributes.ColorMode ): - # device has some color support, check which color modes - # are supported with the featuremap on the ColorControl cluster - color_feature_map = self.get_matter_attribute_value( - clusters.ColorControl.Attributes.FeatureMap, + capabilities = self.get_matter_attribute_value( + clusters.ColorControl.Attributes.ColorCapabilities ) - if ( - color_feature_map - & clusters.ColorControl.Attributes.CurrentHue.attribute_id - ): + + assert capabilities is not None + + if capabilities & ColorCapabilities.kHueSaturationSupported: supported_color_modes.add(ColorMode.HS) - if ( - color_feature_map - & clusters.ColorControl.Attributes.CurrentX.attribute_id - ): + + if capabilities & ColorCapabilities.kXYAttributesSupported: supported_color_modes.add(ColorMode.XY) - # color temperature support detection using the featuremap is not reliable - # (temporary?) fallback to checking the value - if ( - self.get_matter_attribute_value( - clusters.ColorControl.Attributes.ColorTemperatureMireds - ) - is not None - ): + if capabilities & ColorCapabilities.kColorTemperatureSupported: supported_color_modes.add(ColorMode.COLOR_TEMP) self._attr_supported_color_modes = supported_color_modes @@ -351,11 +344,23 @@ class MatterLight(MatterEntity, LightEntity): self._attr_brightness = self._get_brightness() +# This enum should be removed once the ColorControlCapabilities enum is added to the CHIP (Matter) library +# clusters.ColorControl.Bitmap.ColorCapabilities +class ColorCapabilities(IntFlag): + """Color control capabilities bitmap.""" + + kHueSaturationSupported = 0x1 + kEnhancedHueSupported = 0x2 + kColorLoopSupported = 0x4 + kXYAttributesSupported = 0x8 + kColorTemperatureSupported = 0x10 + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.LIGHT, - entity_description=LightEntityDescription(key="ExtendedMatterLight"), + entity_description=LightEntityDescription(key="MatterLight"), entity_class=MatterLight, required_attributes=(clusters.OnOff.Attributes.OnOff,), optional_attributes=( diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index cab1f59f837f..226b22670e6b 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -288,7 +288,7 @@ async def test_extended_color_light( "turn_on", { "entity_id": entity_id, - "hs_color": (0, 0), + "hs_color": (236.69291338582678, 100.0), }, blocking=True, ) @@ -299,9 +299,9 @@ async def test_extended_color_light( call( node_id=1, endpoint_id=1, - command=clusters.ColorControl.Commands.MoveToColor( - colorX=21168, - colorY=21561, + command=clusters.ColorControl.Commands.MoveToHueAndSaturation( + hue=167, + saturation=254, transitionTime=0, optionsMask=0, optionsOverride=0, From 0f204d650253d92f18efcb69330913fd010b6228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 25 Feb 2023 12:01:01 +0100 Subject: [PATCH 0043/1058] Remove homeassistant_hardware after dependency from zha (#88751) --- homeassistant/components/zha/manifest.json | 1 - script/hassfest/dependencies.py | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index c3aefe5987a3..a36373c86256 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -5,7 +5,6 @@ "onboarding", "usb", "zeroconf", - "homeassistant_hardware", "homeassistant_yellow" ], "codeowners": ["@dmulcahey", "@adminiuga", "@puddly"], diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index cadb007e12cf..9f8398d49309 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -146,6 +146,8 @@ IGNORE_VIOLATIONS = { ("demo", "openalpr_local"), # This would be a circular dep ("http", "network"), + # This would be a circular dep + ("zha", "homeassistant_hardware"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), From 7b61d3763b4a33b1fe9127ab657085840b764208 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Feb 2023 05:01:30 -0600 Subject: [PATCH 0044/1058] Log futures that are blocking shutdown stages (#88736) --- homeassistant/core.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/core.py b/homeassistant/core.py index a3f8711c8eac..7268b7d8f242 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -730,6 +730,7 @@ class HomeAssistant: "Timed out waiting for shutdown stage 1 to complete, the shutdown will" " continue" ) + self._async_log_running_tasks(1) # stage 2 self.state = CoreState.final_write @@ -742,6 +743,7 @@ class HomeAssistant: "Timed out waiting for shutdown stage 2 to complete, the shutdown will" " continue" ) + self._async_log_running_tasks(2) # stage 3 self.state = CoreState.not_running @@ -762,11 +764,18 @@ class HomeAssistant: "Timed out waiting for shutdown stage 3 to complete, the shutdown will" " continue" ) + self._async_log_running_tasks(3) + self.state = CoreState.stopped if self._stopped is not None: self._stopped.set() + def _async_log_running_tasks(self, stage: int) -> None: + """Log all running tasks.""" + for task in self._tasks: + _LOGGER.warning("Shutdown stage %s: still running: %s", stage, task) + class Context: """The context that triggered something.""" From 57360a75289f1a3631bf92ad1257974e85619907 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Feb 2023 05:02:07 -0600 Subject: [PATCH 0045/1058] Prevent new discovery flows from being created when stopping (#88743) --- homeassistant/helpers/discovery_flow.py | 4 +++- tests/helpers/test_discovery_flow.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 2bfccf46960e..f7e78e82fb4d 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -44,7 +44,9 @@ def _async_init_flow( # as ones in progress as it may cause additional device probing # which can overload devices since zeroconf/ssdp updates can happen # multiple times in the same minute - if hass.config_entries.flow.async_has_matching_flow(domain, context, data): + if hass.is_stopping or hass.config_entries.flow.async_has_matching_flow( + domain, context, data + ): return None return hass.config_entries.flow.async_init(domain, context=context, data=data) diff --git a/tests/helpers/test_discovery_flow.py b/tests/helpers/test_discovery_flow.py index 3b20782f5b44..9f1d8dfcbc92 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -96,3 +96,20 @@ async def test_async_create_flow_checks_existing_flows_before_startup( data={"properties": {"id": "aa:bb:cc:dd:ee:ff"}}, ) ] + + +async def test_async_create_flow_does_nothing_after_stop( + hass: HomeAssistant, mock_flow_init +) -> None: + """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 + mock_flow_init.reset_mock() + discovery_flow.async_create_flow( + hass, + "hue", + {"source": config_entries.SOURCE_HOMEKIT}, + {"properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + ) + assert len(mock_flow_init.mock_calls) == 0 From 1519a7856747c54abd233dae3ad97d593d1d4036 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Sat, 25 Feb 2023 11:05:24 +0000 Subject: [PATCH 0046/1058] Fix log message in recorder on total_increasing reset (#88710) --- homeassistant/components/sensor/recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 7f8894599770..0d2dc06b83f0 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -588,8 +588,8 @@ def _compile_statistics( # noqa: C901 ), entity_id, new_state, - state.last_updated.isoformat(), fstate, + state.last_updated.isoformat(), ) except HomeAssistantError: continue From b4a3a663cf7f986a4d386a9b8b4809a272085077 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 25 Feb 2023 17:18:49 +0100 Subject: [PATCH 0047/1058] Simplify adding unifi entities (#88571) --- homeassistant/components/unifi/controller.py | 32 ++++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 2721e254de86..31d07278920f 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -198,26 +198,26 @@ class UniFiController: @callback def async_load_entities(description: UnifiEntityDescription) -> None: """Load and subscribe to UniFi endpoints.""" - entities: list[UnifiEntity] = [] api_handler = description.api_handler_fn(self.api) + @callback + def async_add_unifi_entity(obj_ids: list[str]) -> None: + """Add UniFi entity.""" + async_add_entities( + [ + unifi_platform_entity(obj_id, self, description) + for obj_id in obj_ids + if description.allowed_fn(self, obj_id) + if description.supported_fn(self, obj_id) + ] + ) + + async_add_unifi_entity(list(api_handler)) + @callback def async_create_entity(event: ItemEvent, obj_id: str) -> None: - """Create UniFi entity.""" - if not description.allowed_fn( - self, obj_id - ) or not description.supported_fn(self, obj_id): - return - - entity = unifi_platform_entity(obj_id, self, description) - if event == ItemEvent.ADDED: - async_add_entities([entity]) - return - entities.append(entity) - - for obj_id in api_handler: - async_create_entity(ItemEvent.CHANGED, obj_id) - async_add_entities(entities) + """Create new UniFi entity on event.""" + async_add_unifi_entity([obj_id]) api_handler.subscribe(async_create_entity, ItemEvent.ADDED) From 327edabb646b187924d0397885662f25e77e2129 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Feb 2023 21:47:18 -0600 Subject: [PATCH 0048/1058] Fix checking if a package is installed on py3.11 (#88768) pkg_resources is abandoned and we need to move away from using it https://github.com/pypa/pkg_resources In the mean time we need to keep it working. This fixes a new exception in py3.11 when a module is not installed which allows proper fallback to pkg_resources.Requirement.parse when needed ``` 2023-02-25 15:46:21.101 ERROR (MainThread) [aiohttp.server] Error handling request Traceback (most recent call last): File "/opt/homebrew/lib/python3.11/site-packages/aiohttp/web_protocol.py", line 433, in _handle_request resp = await request_handler(request) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/opt/homebrew/lib/python3.11/site-packages/aiohttp/web_app.py", line 504, in _handle resp = await handler(request) ^^^^^^^^^^^^^^^^^^^^^^ File "/opt/homebrew/lib/python3.11/site-packages/aiohttp/web_middlewares.py", line 117, in impl return await handler(request) ^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/components/http/security_filter.py", line 60, in security_filter_middleware return await handler(request) ^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/components/http/forwarded.py", line 100, in forwarded_middleware return await handler(request) ^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/components/http/request_context.py", line 28, in request_context_middleware return await handler(request) ^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/components/http/ban.py", line 80, in ban_middleware return await handler(request) ^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/components/http/auth.py", line 235, in auth_middleware return await handler(request) ^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/components/http/view.py", line 146, in handle result = await result ^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/components/config/config_entries.py", line 148, in post return await super().post(request) ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/components/http/data_validator.py", line 72, in wrapper result = await method(view, request, data, *args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/helpers/data_entry_flow.py", line 71, in post result = await self._flow_mgr.async_init( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/config_entries.py", line 826, in async_init flow, result = await task ^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/config_entries.py", line 844, in _async_init flow = await self.async_create_flow(handler, context=context, data=data) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/config_entries.py", line 950, in async_create_flow await async_process_deps_reqs(self.hass, self._hass_config, integration) File "/Users/bdraco/home-assistant/homeassistant/setup.py", line 384, in async_process_deps_reqs await requirements.async_get_integration_with_requirements( File "/Users/bdraco/home-assistant/homeassistant/requirements.py", line 52, in async_get_integration_with_requirements return await manager.async_get_integration_with_requirements(domain) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/requirements.py", line 171, in async_get_integration_with_requirements await self._async_process_integration(integration, done) File "/Users/bdraco/home-assistant/homeassistant/requirements.py", line 186, in _async_process_integration await self.async_process_requirements( File "/Users/bdraco/home-assistant/homeassistant/requirements.py", line 252, in async_process_requirements await self._async_process_requirements(name, missing) File "/Users/bdraco/home-assistant/homeassistant/requirements.py", line 284, in _async_process_requirements installed, failures = await self.hass.async_add_executor_job( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/opt/homebrew/Cellar/python@3.11/3.11.1/Frameworks/Python.framework/Versions/3.11/lib/python3.11/concurrent/futures/thread.py", line 58, in run result = self.fn(*self.args, **self.kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/requirements.py", line 113, in _install_requirements_if_missing if pkg_util.is_installed(req) or _install_with_retry(req, kwargs): ^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/bdraco/home-assistant/homeassistant/util/package.py", line 40, in is_installed pkg_resources.get_distribution(package) File "/opt/homebrew/lib/python3.11/site-packages/pkg_resources/__init__.py", line 478, in get_distribution dist = get_provider(dist) ^^^^^^^^^^^^^^^^^^ File "/opt/homebrew/lib/python3.11/site-packages/pkg_resources/__init__.py", line 354, in get_provider return working_set.find(moduleOrReq) or require(str(moduleOrReq))[0] ~~~~~~~~~~~~~~~~~~~~~~~~~^^^ IndexError: list index out of range `` --- homeassistant/util/package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index c2c84bf855da..45ceb471fd84 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -39,7 +39,7 @@ def is_installed(package: str) -> bool: try: pkg_resources.get_distribution(package) return True - except (pkg_resources.ResolutionError, pkg_resources.ExtractionError): + except (IndexError, pkg_resources.ResolutionError, pkg_resources.ExtractionError): req = pkg_resources.Requirement.parse(package) except ValueError: # This is a zip file. We no longer use this in Home Assistant, From 490a0908d44393c65801584ef85f6c61f931ec1c Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 26 Feb 2023 01:57:31 -0500 Subject: [PATCH 0049/1058] Avoiding testing implementation details in apcupsd tests (#88772) Fix apcupsd tests. --- tests/components/apcupsd/test_init.py | 31 +++++++++++---------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 93cd817130f6..eae5df9b0c1e 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.apcupsd import DOMAIN, APCUPSdData +from homeassistant.components.apcupsd import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -31,20 +31,20 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No async def test_multiple_integrations(hass: HomeAssistant) -> None: """Test successful setup for multiple entries.""" # Load two integrations from two mock hosts. + status1 = MOCK_STATUS | {"LOADPCT": "15.0 Percent", "SERIALNO": "XXXXX1"} + status2 = MOCK_STATUS | {"LOADPCT": "16.0 Percent", "SERIALNO": "XXXXX2"} entries = ( - await init_integration(hass, host="test1", status=MOCK_STATUS), - await init_integration(hass, host="test2", status=MOCK_MINIMAL_STATUS), + await init_integration(hass, host="test1", status=status1), + await init_integration(hass, host="test2", status=status2), ) - # Data dict should contain different API objects. - assert len(hass.data[DOMAIN]) == len(entries) - for entry in entries: - assert entry.entry_id in hass.data[DOMAIN] - assert isinstance(hass.data[DOMAIN][entry.entry_id], APCUPSdData) + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + assert all(entry.state is ConfigEntryState.LOADED for entry in entries) - assert ( - hass.data[DOMAIN][entries[0].entry_id] != hass.data[DOMAIN][entries[1].entry_id] - ) + state1 = hass.states.get("sensor.ups_load") + state2 = hass.states.get("sensor.ups_load_2") + assert state1 is not None and state2 is not None + assert state1.state != state2.state async def test_connection_error(hass: HomeAssistant) -> None: @@ -83,19 +83,14 @@ async def test_unload_remove(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entries[0].state is ConfigEntryState.NOT_LOADED assert entries[1].state is ConfigEntryState.LOADED - assert len(hass.data[DOMAIN]) == 1 # Unload the second entry. assert await hass.config_entries.async_unload(entries[1].entry_id) await hass.async_block_till_done() assert all(entry.state is ConfigEntryState.NOT_LOADED for entry in entries) - # We should never leave any garbage in the data dict. - assert len(hass.data[DOMAIN]) == 0 - # Remove both entries. for entry in entries: await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - state = hass.states.get(entry.entry_id) - assert state is None + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 From 7c23de469ebd4cd7b81d5a8a2db5ac8412135ffb Mon Sep 17 00:00:00 2001 From: shbatm Date: Sun, 26 Feb 2023 01:12:00 -0600 Subject: [PATCH 0050/1058] Add ISY994 services to set and delete lock codes (#88754) --- homeassistant/components/isy994/lock.py | 56 ++++++++++++++++--- homeassistant/components/isy994/manifest.json | 2 +- homeassistant/components/isy994/services.py | 13 +++++ homeassistant/components/isy994/services.yaml | 46 +++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 111 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py index c5372135bbb9..9bf487def076 100644 --- a/homeassistant/components/isy994/lock.py +++ b/homeassistant/components/isy994/lock.py @@ -8,16 +8,43 @@ from pyisy.constants import ISY_VALUE_UNKNOWN from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) -from .const import _LOGGER, DOMAIN +from .const import DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity +from .services import ( + SERVICE_DELETE_USER_CODE_SCHEMA, + SERVICE_DELETE_ZWAVE_LOCK_USER_CODE, + SERVICE_SET_USER_CODE_SCHEMA, + SERVICE_SET_ZWAVE_LOCK_USER_CODE, +) VALUE_TO_STATE = {0: False, 100: True} +@callback +def async_setup_lock_services(hass: HomeAssistant) -> None: + """Create lock-specific services for the ISY Integration.""" + platform = async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_SET_ZWAVE_LOCK_USER_CODE, + SERVICE_SET_USER_CODE_SCHEMA, + "async_set_zwave_lock_user_code", + ) + platform.async_register_entity_service( + SERVICE_DELETE_ZWAVE_LOCK_USER_CODE, + SERVICE_DELETE_USER_CODE_SCHEMA, + "async_delete_zwave_lock_user_code", + ) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -32,6 +59,7 @@ async def async_setup_entry( entities.append(ISYLockProgramEntity(name, status, actions)) async_add_entities(entities) + async_setup_lock_services(hass) class ISYLockEntity(ISYNodeEntity, LockEntity): @@ -47,12 +75,26 @@ class ISYLockEntity(ISYNodeEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Send the lock command to the ISY device.""" if not await self._node.secure_lock(): - _LOGGER.error("Unable to lock device") + raise HomeAssistantError(f"Unable to lock device {self._node.address}") async def async_unlock(self, **kwargs: Any) -> None: """Send the unlock command to the ISY device.""" if not await self._node.secure_unlock(): - _LOGGER.error("Unable to lock device") + raise HomeAssistantError(f"Unable to unlock device {self._node.address}") + + async def async_set_zwave_lock_user_code(self, user_num: int, code: int) -> None: + """Set a user lock code for a Z-Wave Lock.""" + if not await self._node.set_zwave_lock_code(user_num, code): + raise HomeAssistantError( + f"Could not set user code {user_num} for {self._node.address}" + ) + + async def async_delete_zwave_lock_user_code(self, user_num: int) -> None: + """Delete a user lock code for a Z-Wave Lock.""" + if not await self._node.delete_zwave_lock_code(user_num): + raise HomeAssistantError( + f"Could not delete user code {user_num} for {self._node.address}" + ) class ISYLockProgramEntity(ISYProgramEntity, LockEntity): @@ -66,9 +108,9 @@ class ISYLockProgramEntity(ISYProgramEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" if not await self._actions.run_then(): - _LOGGER.error("Unable to lock device") + raise HomeAssistantError(f"Unable to lock device {self._node.address}") async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" if not await self._actions.run_else(): - _LOGGER.error("Unable to unlock device") + raise HomeAssistantError(f"Unable to unlock device {self._node.address}") diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 991b79e7be9c..3aa81027b4f7 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -24,7 +24,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], - "requirements": ["pyisy==3.1.13"], + "requirements": ["pyisy==3.1.14"], "ssdp": [ { "manufacturer": "Universal Devices Inc.", diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 05e0425c3f5f..ea66bc90130f 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -52,8 +52,14 @@ SERVICE_RENAME_NODE = "rename_node" SERVICE_SET_ON_LEVEL = "set_on_level" SERVICE_SET_RAMP_RATE = "set_ramp_rate" +# Services valid only for Z-Wave Locks +SERVICE_SET_ZWAVE_LOCK_USER_CODE = "set_zwave_lock_user_code" +SERVICE_DELETE_ZWAVE_LOCK_USER_CODE = "delete_zwave_lock_user_code" + CONF_PARAMETER = "parameter" CONF_PARAMETERS = "parameters" +CONF_USER_NUM = "user_num" +CONF_CODE = "code" CONF_VALUE = "value" CONF_INIT = "init" CONF_ISY = "isy" @@ -129,6 +135,13 @@ SERVICE_SET_ZWAVE_PARAMETER_SCHEMA = { vol.Required(CONF_SIZE): vol.All(vol.Coerce(int), vol.In(VALID_PARAMETER_SIZES)), } +SERVICE_SET_USER_CODE_SCHEMA = { + vol.Required(CONF_USER_NUM): vol.Coerce(int), + vol.Required(CONF_CODE): vol.Coerce(int), +} + +SERVICE_DELETE_USER_CODE_SCHEMA = {vol.Required(CONF_USER_NUM): vol.Coerce(int)} + SERVICE_SET_VARIABLE_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_ADDRESS, CONF_TYPE, CONF_NAME), vol.Schema( diff --git a/homeassistant/components/isy994/services.yaml b/homeassistant/components/isy994/services.yaml index e336eaa574be..89b6c4d33d3a 100644 --- a/homeassistant/components/isy994/services.yaml +++ b/homeassistant/components/isy994/services.yaml @@ -118,6 +118,52 @@ set_zwave_parameter: - "1" - "2" - "4" +set_zwave_lock_user_code: + name: Set Z-Wave Lock User Code + description: >- + Set a Z-Wave Lock User Code via the ISY. + target: + entity: + integration: isy994 + domain: lock + fields: + user_num: + name: User Number + description: The user slot number on the lock + required: true + example: 8 + selector: + number: + min: 1 + max: 255 + code: + name: Code + description: The code to set for the user. + required: true + example: 33491663 + selector: + number: + min: 1 + max: 99999999 + mode: box +delete_zwave_lock_user_code: + name: Delete Z-Wave Lock User Code + description: >- + Delete a Z-Wave Lock User Code via the ISY. + target: + entity: + integration: isy994 + domain: lock + fields: + user_num: + name: User Number + description: The user slot number on the lock + required: true + example: 8 + selector: + number: + min: 1 + max: 255 rename_node: name: Rename Node on ISY description: >- diff --git a/requirements_all.txt b/requirements_all.txt index 9fae25122a4f..4d903ffaaefa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1708,7 +1708,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.13 +pyisy==3.1.14 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 22c4b7e4dff9..ecda31f4c362 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1227,7 +1227,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.1.13 +pyisy==3.1.14 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 From e00ff548690723f0ac0a8bbb511b911c708022b2 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 26 Feb 2023 11:05:31 +0100 Subject: [PATCH 0051/1058] Update nibe library to 2.0.0 (#88769) --- .../components/nibe_heatpump/__init__.py | 46 +++++++++---------- .../components/nibe_heatpump/binary_sensor.py | 6 +-- .../components/nibe_heatpump/config_flow.py | 18 ++++---- .../components/nibe_heatpump/manifest.json | 2 +- .../components/nibe_heatpump/number.py | 8 ++-- .../components/nibe_heatpump/select.py | 8 ++-- .../components/nibe_heatpump/sensor.py | 6 +-- .../components/nibe_heatpump/switch.py | 6 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nibe_heatpump/conftest.py | 11 ++--- tests/components/nibe_heatpump/test_button.py | 6 +-- .../nibe_heatpump/test_config_flow.py | 12 ++--- 13 files changed, 66 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index 57c5f680e01b..fd77b5e23442 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -8,11 +8,11 @@ from datetime import timedelta from functools import cached_property from typing import Any, Generic, TypeVar -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from nibe.connection import Connection from nibe.connection.modbus import Modbus from nibe.connection.nibegw import NibeGW, ProductInfo -from nibe.exceptions import CoilNotFoundException, CoilReadException +from nibe.exceptions import CoilNotFoundException, ReadException from nibe.heatpump import HeatPump, Model, Series from homeassistant.config_entries import ConfigEntry @@ -182,7 +182,7 @@ class ContextCoordinator( return release_update -class Coordinator(ContextCoordinator[dict[int, Coil], int]): +class Coordinator(ContextCoordinator[dict[int, CoilData], int]): """Update coordinator for nibe heat pumps.""" config_entry: ConfigEntry @@ -199,17 +199,18 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): ) self.data = {} - self.seed: dict[int, Coil] = {} + self.seed: dict[int, CoilData] = {} self.connection = connection self.heatpump = heatpump self.task: asyncio.Task | None = None heatpump.subscribe(heatpump.COIL_UPDATE_EVENT, self._on_coil_update) - def _on_coil_update(self, coil: Coil): + def _on_coil_update(self, data: CoilData): """Handle callback on coil updates.""" - self.data[coil.address] = coil - self.seed[coil.address] = coil + coil = data.coil + self.data[coil.address] = data + self.seed[coil.address] = data self.async_update_context_listeners([coil.address]) @property @@ -246,26 +247,26 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): async def async_write_coil(self, coil: Coil, value: int | float | str) -> None: """Write coil and update state.""" - coil.value = value - coil = await self.connection.write_coil(coil) + data = CoilData(coil, value) + await self.connection.write_coil(data) - self.data[coil.address] = coil + self.data[coil.address] = data self.async_update_context_listeners([coil.address]) - async def async_read_coil(self, coil: Coil) -> Coil: + async def async_read_coil(self, coil: Coil) -> CoilData: """Read coil and update state using callbacks.""" return await self.connection.read_coil(coil) - async def _async_update_data(self) -> dict[int, Coil]: + async def _async_update_data(self) -> dict[int, CoilData]: self.task = asyncio.current_task() try: return await self._async_update_data_internal() finally: self.task = None - async def _async_update_data_internal(self) -> dict[int, Coil]: - result: dict[int, Coil] = {} + async def _async_update_data_internal(self) -> dict[int, CoilData]: + result: dict[int, CoilData] = {} def _get_coils() -> Iterable[Coil]: for address in sorted(self.context_callbacks.keys()): @@ -282,10 +283,10 @@ class Coordinator(ContextCoordinator[dict[int, Coil], int]): yield coil try: - async for coil in self.connection.read_coils(_get_coils()): - result[coil.address] = coil - self.seed.pop(coil.address, None) - except CoilReadException as exception: + async for data in self.connection.read_coils(_get_coils()): + result[data.coil.address] = data + self.seed.pop(data.coil.address, None) + except ReadException as exception: if not result: raise UpdateFailed(f"Failed to update: {exception}") from exception self.logger.debug( @@ -329,7 +330,7 @@ class CoilEntity(CoordinatorEntity[Coordinator]): self.coordinator.data or {} ) - def _async_read_coil(self, coil: Coil): + def _async_read_coil(self, data: CoilData): """Update state of entity based on coil data.""" async def _async_write_coil(self, value: int | float | str): @@ -337,10 +338,9 @@ class CoilEntity(CoordinatorEntity[Coordinator]): await self.coordinator.async_write_coil(self._coil, value) def _handle_coordinator_update(self) -> None: - coil = self.coordinator.data.get(self._coil.address) - if coil is None: + data = self.coordinator.data.get(self._coil.address) + if data is None: return - self._coil = coil - self._async_read_coil(coil) + self._async_read_coil(data) self.async_write_ha_state() diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 89c993cafaae..263fd41b3095 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -1,7 +1,7 @@ """The Nibe Heat Pump binary sensors.""" from __future__ import annotations -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorEntity from homeassistant.config_entries import ConfigEntry @@ -37,5 +37,5 @@ class BinarySensor(CoilEntity, BinarySensorEntity): """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) - def _async_read_coil(self, coil: Coil) -> None: - self._attr_is_on = coil.value == "ON" + def _async_read_coil(self, data: CoilData) -> None: + self._attr_is_on = data.value == "ON" diff --git a/homeassistant/components/nibe_heatpump/config_flow.py b/homeassistant/components/nibe_heatpump/config_flow.py index 6050010b20d0..434a9a50ea68 100644 --- a/homeassistant/components/nibe_heatpump/config_flow.py +++ b/homeassistant/components/nibe_heatpump/config_flow.py @@ -8,10 +8,10 @@ from nibe.connection.nibegw import NibeGW from nibe.exceptions import ( AddressInUseException, CoilNotFoundException, - CoilReadException, - CoilReadSendException, - CoilWriteException, CoilWriteSendException, + ReadException, + ReadSendException, + WriteException, ) from nibe.heatpump import HeatPump, Model import voluptuous as vol @@ -108,13 +108,13 @@ async def validate_nibegw_input( try: await connection.verify_connectivity() - except (CoilReadSendException, CoilWriteSendException) as exception: + except (ReadSendException, CoilWriteSendException) as exception: raise FieldError(str(exception), CONF_IP_ADDRESS, "address") from exception except CoilNotFoundException as exception: raise FieldError("Coils not found", "base", "model") from exception - except CoilReadException as exception: + except ReadException as exception: raise FieldError("Timeout on read from pump", "base", "read") from exception - except CoilWriteException as exception: + except WriteException as exception: raise FieldError("Timeout on writing to pump", "base", "write") from exception finally: await connection.stop() @@ -147,13 +147,13 @@ async def validate_modbus_input( try: await connection.verify_connectivity() - except (CoilReadSendException, CoilWriteSendException) as exception: + except (ReadSendException, CoilWriteSendException) as exception: raise FieldError(str(exception), CONF_MODBUS_URL, "address") from exception except CoilNotFoundException as exception: raise FieldError("Coils not found", "base", "model") from exception - except CoilReadException as exception: + except ReadException as exception: raise FieldError("Timeout on read from pump", "base", "read") from exception - except CoilWriteException as exception: + except WriteException as exception: raise FieldError("Timeout on writing to pump", "base", "write") from exception finally: await connection.stop() diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index d9a2bd365e92..5114cc222e91 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==1.6.0"] + "requirements": ["nibe==2.0.0"] } diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 579b8e791567..79078811881a 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -1,7 +1,7 @@ """The Nibe Heat Pump numbers.""" from __future__ import annotations -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity from homeassistant.config_entries import ConfigEntry @@ -58,13 +58,13 @@ class Number(CoilEntity, NumberEntity): self._attr_native_unit_of_measurement = coil.unit self._attr_native_value = None - def _async_read_coil(self, coil: Coil) -> None: - if coil.value is None: + def _async_read_coil(self, data: CoilData) -> None: + if data.value is None: self._attr_native_value = None return try: - self._attr_native_value = float(coil.value) + self._attr_native_value = float(data.value) except ValueError: self._attr_native_value = None diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index d554eaf4ff08..e255ff365000 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -1,7 +1,7 @@ """The Nibe Heat Pump select.""" from __future__ import annotations -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity from homeassistant.config_entries import ConfigEntry @@ -40,12 +40,12 @@ class Select(CoilEntity, SelectEntity): self._attr_options = list(coil.mappings.values()) self._attr_current_option = None - def _async_read_coil(self, coil: Coil) -> None: - if not isinstance(coil.value, str): + def _async_read_coil(self, data: CoilData) -> None: + if not isinstance(data.value, str): self._attr_current_option = None return - self._attr_current_option = coil.value + self._attr_current_option = data.value async def async_select_option(self, option: str) -> None: """Support writing value.""" diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index 94f37040486e..8aabad2c9fc0 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -1,7 +1,7 @@ """The Nibe Heat Pump sensors.""" from __future__ import annotations -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.sensor import ( ENTITY_ID_FORMAT, @@ -146,5 +146,5 @@ class Sensor(CoilEntity, SensorEntity): self._attr_native_unit_of_measurement = coil.unit self._attr_entity_category = EntityCategory.DIAGNOSTIC - def _async_read_coil(self, coil: Coil): - self._attr_native_value = coil.value + def _async_read_coil(self, data: CoilData): + self._attr_native_value = data.value diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 23634e77c52c..95d96de9764d 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -40,8 +40,8 @@ class Switch(CoilEntity, SwitchEntity): super().__init__(coordinator, coil, ENTITY_ID_FORMAT) self._attr_is_on = None - def _async_read_coil(self, coil: Coil) -> None: - self._attr_is_on = coil.value == "ON" + def _async_read_coil(self, data: CoilData) -> None: + self._attr_is_on = data.value == "ON" async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/requirements_all.txt b/requirements_all.txt index 4d903ffaaefa..10dfef7810a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ nextcord==2.0.0a8 nextdns==1.3.0 # homeassistant.components.nibe_heatpump -nibe==1.6.0 +nibe==2.0.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 ecda31f4c362..3b67a5887297 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -891,7 +891,7 @@ nextcord==2.0.0a8 nextdns==1.3.0 # homeassistant.components.nibe_heatpump -nibe==1.6.0 +nibe==2.0.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index 0ae9c73e6b87..b75c49b2b79e 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -4,9 +4,9 @@ from contextlib import ExitStack from typing import Any from unittest.mock import AsyncMock, Mock, patch -from nibe.coil import Coil +from nibe.coil import Coil, CoilData from nibe.connection import Connection -from nibe.exceptions import CoilReadException +from nibe.exceptions import ReadException import pytest @@ -39,12 +39,11 @@ async def fixture_coils(mock_connection): """Return a dict with coil data.""" coils: dict[int, Any] = {} - async def read_coil(coil: Coil, timeout: float = 0) -> Coil: + async def read_coil(coil: Coil, timeout: float = 0) -> CoilData: nonlocal coils if (data := coils.get(coil.address, None)) is None: - raise CoilReadException() - coil.value = data - return coil + raise ReadException() + return CoilData(coil, data) async def read_coils( coils: Iterable[Coil], timeout: float = 0 diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index 0ced1799b480..e4f90a59f67d 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from nibe.coil import Coil +from nibe.coil import CoilData from nibe.coil_groups import UNIT_COILGROUPS from nibe.heatpump import Model import pytest @@ -91,6 +91,6 @@ async def test_reset_button( # Verify reset was written args = mock_connection.write_coil.call_args assert args - coil: Coil = args.args[0] - assert coil.address == unit.alarm_reset + coil: CoilData = args.args[0] + assert coil.coil.address == unit.alarm_reset assert coil.value == 1 diff --git a/tests/components/nibe_heatpump/test_config_flow.py b/tests/components/nibe_heatpump/test_config_flow.py index 9263919214d3..3360c82577fc 100644 --- a/tests/components/nibe_heatpump/test_config_flow.py +++ b/tests/components/nibe_heatpump/test_config_flow.py @@ -5,9 +5,9 @@ from nibe.coil import Coil from nibe.exceptions import ( AddressInUseException, CoilNotFoundException, - CoilReadException, - CoilReadSendException, - CoilWriteException, + ReadException, + ReadSendException, + WriteException, ) import pytest @@ -169,7 +169,7 @@ async def test_read_timeout( """Test we handle cannot connect error.""" result = await _get_connection_form(hass, connection_type) - mock_connection.verify_connectivity.side_effect = CoilReadException() + mock_connection.verify_connectivity.side_effect = ReadException() result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) @@ -190,7 +190,7 @@ async def test_write_timeout( """Test we handle cannot connect error.""" result = await _get_connection_form(hass, connection_type) - mock_connection.verify_connectivity.side_effect = CoilWriteException() + mock_connection.verify_connectivity.side_effect = WriteException() result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) @@ -232,7 +232,7 @@ async def test_nibegw_invalid_host( """Test we handle cannot connect error.""" result = await _get_connection_form(hass, connection_type) - mock_connection.verify_connectivity.side_effect = CoilReadSendException() + mock_connection.verify_connectivity.side_effect = ReadSendException() result2 = await hass.config_entries.flow.async_configure(result["flow_id"], data) From c9dfa15ed611340ed30045486e207ee93247b890 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Sun, 26 Feb 2023 18:49:25 +0100 Subject: [PATCH 0052/1058] Add device HmIP-DLD (#83380) * Add HmIP-DLD * Remove commented code * Fix errors * Format using black * Fix device count * Add missing tests * Apply changes by reviewer * Change setup entry code * Remove jammed state * Add error messages * Update homeassistant/components/homematicip_cloud/helpers.py Co-authored-by: Aaron Bach * Add decorator * Add error log output * Update test_device.py --------- Co-authored-by: Aaron Bach --- .../components/homematicip_cloud/const.py | 1 + .../components/homematicip_cloud/helpers.py | 39 ++++ .../components/homematicip_cloud/lock.py | 95 ++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_helpers.py | 18 ++ .../components/homematicip_cloud/test_lock.py | 127 +++++++++++++ tests/fixtures/homematicip_cloud.json | 177 +++++++++++++++++- 7 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/homematicip_cloud/helpers.py create mode 100644 homeassistant/components/homematicip_cloud/lock.py create mode 100644 tests/components/homematicip_cloud/test_helpers.py create mode 100644 tests/components/homematicip_cloud/test_lock.py diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 055db90a68cb..4ea1a2fc7e01 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -14,6 +14,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.LOCK, Platform.SENSOR, Platform.SWITCH, Platform.WEATHER, diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py new file mode 100644 index 000000000000..1680904bbca5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -0,0 +1,39 @@ +"""Helper functions for Homematicip Cloud Integration.""" + +from functools import wraps +import json +import logging + +from homeassistant.exceptions import HomeAssistantError + +from . import HomematicipGenericEntity + +_LOGGER = logging.getLogger(__name__) + + +def is_error_response(response) -> bool: + """Response from async call contains errors or not.""" + if isinstance(response, dict): + return response.get("errorCode") not in ("", None) + + return False + + +def handle_errors(func): + """Handle async errors.""" + + @wraps(func) + async def inner(self: HomematicipGenericEntity) -> None: + """Handle errors from async call.""" + result = await func(self) + if is_error_response(result): + _LOGGER.error( + "Error while execute function %s: %s", + __name__, + json.dumps(result), + ) + raise HomeAssistantError( + f"Error while execute function {func.__name__}: {result.get('errorCode')}. See log for more information." + ) + + return inner diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py new file mode 100644 index 000000000000..563f0103060a --- /dev/null +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -0,0 +1,95 @@ +"""Support for HomematicIP Cloud lock devices.""" +from __future__ import annotations + +import logging +from typing import Any + +from homematicip.aio.device import AsyncDoorLockDrive +from homematicip.base.enums import LockState, MotorState + +from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .helpers import handle_errors + +_LOGGER = logging.getLogger(__name__) + +ATTR_AUTO_RELOCK_DELAY = "auto_relock_delay" +ATTR_DOOR_HANDLE_TYPE = "door_handle_type" +ATTR_DOOR_LOCK_DIRECTION = "door_lock_direction" +ATTR_DOOR_LOCK_NEUTRAL_POSITION = "door_lock_neutral_position" +ATTR_DOOR_LOCK_TURNS = "door_lock_turns" + +DEVICE_DLD_ATTRIBUTES = { + "autoRelockDelay": ATTR_AUTO_RELOCK_DELAY, + "doorHandleType": ATTR_DOOR_HANDLE_TYPE, + "doorLockDirection": ATTR_DOOR_LOCK_DIRECTION, + "doorLockNeutralPosition": ATTR_DOOR_LOCK_NEUTRAL_POSITION, + "doorLockTurns": ATTR_DOOR_LOCK_TURNS, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the HomematicIP locks from a config entry.""" + hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + + async_add_entities( + HomematicipDoorLockDrive(hap, device) + for device in hap.home.devices + if isinstance(device, AsyncDoorLockDrive) + ) + + +class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity): + """Representation of the HomematicIP DoorLockDrive.""" + + _attr_supported_features = LockEntityFeature.OPEN + + @property + def is_locked(self) -> bool | None: + """Return true if device is locked.""" + return ( + self._device.lockState == LockState.LOCKED + and self._device.motorState == MotorState.STOPPED + ) + + @property + def is_locking(self) -> bool: + """Return true if device is locking.""" + return self._device.motorState == MotorState.CLOSING + + @property + def is_unlocking(self) -> bool: + """Return true if device is unlocking.""" + return self._device.motorState == MotorState.OPENING + + @handle_errors + async def async_lock(self, **kwargs: Any) -> None: + """Lock the device.""" + return await self._device.set_lock_state(LockState.LOCKED) + + @handle_errors + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the device.""" + return await self._device.set_lock_state(LockState.UNLOCKED) + + @handle_errors + async def async_open(self, **kwargs: Any) -> None: + """Open the door latch.""" + return await self._device.set_lock_state(LockState.OPEN) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the device.""" + return super().extra_state_attributes | { + attr_key: attr_value + for attr, attr_key in DEVICE_DLD_ATTRIBUTES.items() + if (attr_value := getattr(self._device, attr, None)) is not None + } diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 60d8c4d65549..d84fe690df61 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -25,7 +25,7 @@ async def test_hmip_load_all_supported_devices( test_devices=None, test_groups=None ) - assert len(mock_hap.hmip_device_by_entity_id) == 270 + assert len(mock_hap.hmip_device_by_entity_id) == 272 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_helpers.py b/tests/components/homematicip_cloud/test_helpers.py new file mode 100644 index 000000000000..85c16255d714 --- /dev/null +++ b/tests/components/homematicip_cloud/test_helpers.py @@ -0,0 +1,18 @@ +"""Test HomematicIP Cloud helper functions.""" + +import json + +from homeassistant.components.homematicip_cloud.helpers import is_error_response + + +async def test_is_error_response(): + """Test, if an response is a normal result or an error.""" + assert not is_error_response("True") + assert not is_error_response(True) + assert not is_error_response("") + assert is_error_response( + json.loads( + '{"errorCode": "INVALID_NUMBER_PARAMETER_VALUE", "minValue": 0.0, "maxValue": 1.01}' + ) + ) + assert not is_error_response(json.loads('{"errorCode": ""}')) diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py new file mode 100644 index 000000000000..48ae02738a6a --- /dev/null +++ b/tests/components/homematicip_cloud/test_lock.py @@ -0,0 +1,127 @@ +"""Tests for HomematicIP Cloud locks.""" +from unittest.mock import patch + +from homematicip.base.enums import LockState, MotorState +import pytest + +from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN +from homeassistant.components.lock import ( + DOMAIN, + STATE_LOCKING, + STATE_UNLOCKING, + LockEntityFeature, +) +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from .helper import async_manipulate_test_data, get_and_check_entity_basics + + +async def test_manually_configured_platform(hass): + """Test that we do not set up an access point.""" + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {"platform": HMIPC_DOMAIN}} + ) + assert not hass.data.get(HMIPC_DOMAIN) + + +async def test_hmip_doorlockdrive(hass, default_mock_hap_factory): + """Test HomematicipDoorLockDrive.""" + entity_id = "lock.haustuer" + entity_name = "Haustuer" + device_model = "HmIP-DLD" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=[entity_name] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == LockEntityFeature.OPEN + + await hass.services.async_call( + "lock", + "open", + {"entity_id": entity_id}, + blocking=True, + ) + assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][1] == (LockState.OPEN,) + + await hass.services.async_call( + "lock", + "lock", + {"entity_id": entity_id}, + blocking=True, + ) + assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][1] == (LockState.LOCKED,) + + await hass.services.async_call( + "lock", + "unlock", + {"entity_id": entity_id}, + blocking=True, + ) + + assert hmip_device.mock_calls[-1][0] == "set_lock_state" + assert hmip_device.mock_calls[-1][1] == (LockState.UNLOCKED,) + + await async_manipulate_test_data( + hass, hmip_device, "motorState", MotorState.CLOSING + ) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_LOCKING + + await async_manipulate_test_data( + hass, hmip_device, "motorState", MotorState.OPENING + ) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_UNLOCKING + + +async def test_hmip_doorlockdrive_handle_errors(hass, default_mock_hap_factory): + """Test HomematicipDoorLockDrive.""" + entity_id = "lock.haustuer" + entity_name = "Haustuer" + device_model = "HmIP-DLD" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=[entity_name] + ) + with patch( + "homematicip.aio.device.AsyncDoorLockDrive.set_lock_state", + return_value={ + "errorCode": "INVALID_NUMBER_PARAMETER_VALUE", + "minValue": 0.0, + "maxValue": 1.01, + }, + ): + get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + "open", + {"entity_id": entity_id}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + "lock", + {"entity_id": entity_id}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "lock", + "unlock", + {"entity_id": entity_id}, + blocking=True, + ) diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index c54327069a20..d050300971c4 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -6978,7 +6978,7 @@ "supported": true, "type": "EXTERNAL" }, - "3014F711A000DIN_RAIL_DIMMER3": { +"3014F711A000DIN_RAIL_DIMMER3": { "availableFirmwareVersion": "1.2.0", "connectionType": "HMIP_RF", "firmwareVersion": "1.2.0", @@ -7119,6 +7119,181 @@ "serializedGlobalTradeItemNumber": "3014F711A000DIN_RAIL_DIMMER3", "type": "DIN_RAIL_DIMMER_3", "updateState": "UP_TO_DATE" + }, + "3014F7110000000000000DLD": { + "availableFirmwareVersion": "1.2.0", + "connectionType": "HMIP_RF", + "firmwareVersion": "1.2.0", + "firmwareVersionInteger": 66048, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "deviceId": "3014F7110000000000000DLD", + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "dutyCycle": false, + "functionalChannelType": "DEVICE_OPERATIONLOCK", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000025"], + "index": 0, + "label": "", + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "operationLockActive": false, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": false, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -63, + "rssiPeerValue": -64, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": true, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "autoRelockDelay": 300.0, + "autoRelockEnabled": false, + "deviceId": "3014F7110000000000000DLD", + "doorHandleType": "LEVER_HANDLE", + "doorLockDirection": "RIGHT", + "doorLockNeutralPosition": "VERTICAL", + "doorLockTurns": 2, + "functionalChannelType": "DOOR_LOCK_CHANNEL", + "groupIndex": 1, + "groups": [ + "00000000-0000-0000-0000-000000000026", + "00000000-0000-0000-0000-000000000027", + "00000000-0000-0000-0000-000000000028" + ], + "index": 1, + "label": "", + "lockState": "LOCKED", + "motorState": "STOPPED" + }, + "2": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 2, + "label": "" + }, + "3": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 3, + "label": "" + }, + "4": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 4, + "label": "" + }, + "5": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 5, + "label": "" + }, + "6": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 6, + "label": "" + }, + "7": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 7, + "label": "" + }, + "8": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000033"], + "index": 8, + "label": "" + }, + "9": { + "authorized": true, + "deviceId": "3014F7110000000000000DLD", + "functionalChannelType": "ACCESS_AUTHORIZATION_CHANNEL", + "groupIndex": 1, + "groups": [ + "00000000-0000-0000-0000-000000000033", + "00000000-0000-0000-0000-000000000032" + ], + "index": 9, + "label": "" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000000DLD", + "label": "Haustuer", + "lastStatusUpdate": 1618727020725, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 423, + "modelType": "HmIP-DLD", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F7110000000000000DLD", + "type": "DOOR_LOCK_DRIVE", + "updateState": "UP_TO_DATE" } }, "groups": { From 0fb41bdffe728ba7877a5ec44f4cc1dbd941ce98 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 26 Feb 2023 12:41:16 -0800 Subject: [PATCH 0053/1058] Unblock JSON CI by fixing improperly indented JSON in test fixture (#88803) --- tests/fixtures/homematicip_cloud.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index d050300971c4..83b5f8993bce 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -6978,7 +6978,7 @@ "supported": true, "type": "EXTERNAL" }, -"3014F711A000DIN_RAIL_DIMMER3": { + "3014F711A000DIN_RAIL_DIMMER3": { "availableFirmwareVersion": "1.2.0", "connectionType": "HMIP_RF", "firmwareVersion": "1.2.0", From 588b51bdfa00e6aaf52796f3493239750c008e0d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Feb 2023 21:45:14 +0100 Subject: [PATCH 0054/1058] Simplify reolink update unique_id (#88794) simplify unique_id --- homeassistant/components/reolink/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 51a969771723..5752afc92aca 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -49,7 +49,7 @@ class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): """Initialize a Netgear device.""" super().__init__(reolink_data, reolink_data.firmware_coordinator) - self._attr_unique_id = f"{self._host.unique_id}_update" + self._attr_unique_id = f"{self._host.unique_id}" @property def installed_version(self) -> str | None: From 0f018665080c6eaf53153b5794fad5e8b9d4a046 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Feb 2023 21:49:24 +0100 Subject: [PATCH 0055/1058] Do not block on reolink firmware check fail (#88797) Do not block on firmware check fail --- homeassistant/components/reolink/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 6633f5c02f20..2faa89232afa 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -106,9 +106,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) # Fetch initial data so we have data when entities subscribe try: + # If camera WAN blocked, firmware check fails, do not prevent setup await asyncio.gather( device_coordinator.async_config_entry_first_refresh(), - firmware_coordinator.async_config_entry_first_refresh(), + firmware_coordinator.async_refresh(), ) except ConfigEntryNotReady: await host.stop() From bea81d3f631a743aec25eb61bf0eabb93d25eba2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 17:59:28 -0600 Subject: [PATCH 0056/1058] Fix lock services not removing entity fields (#88805) --- homeassistant/components/lock/__init__.py | 7 ++++--- homeassistant/helpers/service.py | 16 +++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 86a63538a681..c68d99bfb22a 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.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.service import remove_entity_service_fields from homeassistant.helpers.typing import ConfigType, StateType _LOGGER = logging.getLogger(__name__) @@ -92,7 +93,7 @@ async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: raise ValueError( f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" ) - await entity.async_lock(**service_call.data) + await entity.async_lock(**remove_entity_service_fields(service_call)) async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: @@ -102,7 +103,7 @@ async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: raise ValueError( f"Code '{code}' for unlocking {entity.entity_id} doesn't match pattern {entity.code_format}" ) - await entity.async_unlock(**service_call.data) + await entity.async_unlock(**remove_entity_service_fields(service_call)) async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: @@ -112,7 +113,7 @@ async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: raise ValueError( f"Code '{code}' for opening {entity.entity_id} doesn't match pattern {entity.code_format}" ) - await entity.async_open(**service_call.data) + await entity.async_open(**remove_entity_service_fields(service_call)) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3c3da10db7c7..9f6f65f1d2de 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -513,6 +513,16 @@ async def async_get_all_descriptions( return descriptions +@callback +def remove_entity_service_fields(call: ServiceCall) -> dict[Any, Any]: + """Remove entity service fields.""" + return { + key: val + for key, val in call.data.items() + if key not in cv.ENTITY_SERVICE_FIELDS + } + + @callback @bind_hass def async_set_service_schema( @@ -567,11 +577,7 @@ async def entity_service_call( # noqa: C901 # If the service function is a string, we'll pass it the service call data if isinstance(func, str): - data: dict | ServiceCall = { - key: val - for key, val in call.data.items() - if key not in cv.ENTITY_SERVICE_FIELDS - } + data: dict | ServiceCall = remove_entity_service_fields(call) # If the service function is not a string, we pass the service call else: data = call From 9be3f86a4cff83b2ea5805a3f4bd10f201c0bc1b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Feb 2023 20:25:29 -0500 Subject: [PATCH 0057/1058] Check circular dependencies (#88778) --- homeassistant/components/hassio/manifest.json | 1 - homeassistant/components/zha/manifest.json | 7 +-- script/hassfest/dependencies.py | 58 +++++++++++++++++-- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index bbc50fe7a582..70fc024c005a 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -1,7 +1,6 @@ { "domain": "hassio", "name": "Home Assistant Supervisor", - "after_dependencies": ["panel_custom"], "codeowners": ["@home-assistant/supervisor"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/hassio", diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a36373c86256..1e0d8999d300 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -1,12 +1,7 @@ { "domain": "zha", "name": "Zigbee Home Automation", - "after_dependencies": [ - "onboarding", - "usb", - "zeroconf", - "homeassistant_yellow" - ], + "after_dependencies": ["onboarding", "usb"], "codeowners": ["@dmulcahey", "@adminiuga", "@puddly"], "config_flow": true, "dependencies": ["file_upload"], diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 9f8398d49309..8d2f179aef42 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -2,6 +2,7 @@ from __future__ import annotations import ast +from collections import deque from pathlib import Path from homeassistant.const import Platform @@ -118,6 +119,7 @@ ALLOWED_USED_COMPONENTS = { "input_text", "media_source", "onboarding", + "panel_custom", "persistent_notification", "person", "script", @@ -138,22 +140,19 @@ IGNORE_VIOLATIONS = { # Has same requirement, gets defaults. ("sql", "recorder"), # Sharing a base class - ("openalpr_cloud", "openalpr_local"), ("lutron_caseta", "lutron"), ("ffmpeg_noise", "ffmpeg_motion"), # Demo ("demo", "manual"), - ("demo", "openalpr_local"), # This would be a circular dep ("http", "network"), # This would be a circular dep ("zha", "homeassistant_hardware"), + ("zha", "homeassistant_yellow"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), "logbook", - # Migration wizard from zwave to zwave_js. - "zwave_js", } @@ -231,6 +230,7 @@ def find_non_referenced_integrations( def validate_dependencies( integrations: dict[str, Integration], integration: Integration, + check_dependencies: bool, ) -> None: """Validate all dependencies.""" # Some integrations are allowed to have violations. @@ -252,12 +252,60 @@ def validate_dependencies( "or 'after_dependencies'", ) + if check_dependencies: + _check_circular_deps( + integrations, integration.domain, integration, set(), deque() + ) + + +def _check_circular_deps( + integrations: dict[str, Integration], + start_domain: str, + integration: Integration, + checked: set[str], + checking: deque[str], +) -> None: + """Check for circular dependencies pointing at starting_domain.""" + if integration.domain in checked or integration.domain in checking: + return + + checking.append(integration.domain) + for domain in integration.manifest.get("dependencies", []): + if domain == start_domain: + integrations[start_domain].add_error( + "dependencies", + f"Found a circular dependency with {integration.domain} ({', '.join(checking)})", + ) + break + + _check_circular_deps( + integrations, start_domain, integrations[domain], checked, checking + ) + else: + for domain in integration.manifest.get("after_dependencies", []): + if domain == start_domain: + integrations[start_domain].add_error( + "dependencies", + f"Found a circular dependency with after dependencies of {integration.domain} ({', '.join(checking)})", + ) + break + + _check_circular_deps( + integrations, start_domain, integrations[domain], checked, checking + ) + checked.add(integration.domain) + checking.remove(integration.domain) + def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle dependencies for integrations.""" # check for non-existing dependencies for integration in integrations.values(): - validate_dependencies(integrations, integration) + validate_dependencies( + integrations, + integration, + check_dependencies=not config.specific_integrations, + ) if config.specific_integrations: continue From c8fc2dc4402c9f1d79f5d55282bac0671ad442fc Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 27 Feb 2023 01:25:55 +0000 Subject: [PATCH 0058/1058] Add Camera platform to Prosegur (#76428) * add camera to prosegur * add tests * address review * better tests * clean * clean * fix tests * leftover from merge * sorting missing * Update homeassistant/components/prosegur/services.yaml Co-authored-by: Paulus Schoutsen --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/prosegur/__init__.py | 2 +- .../prosegur/alarm_control_panel.py | 9 ++ homeassistant/components/prosegur/camera.py | 97 +++++++++++++++++++ homeassistant/components/prosegur/const.py | 2 + .../components/prosegur/diagnostics.py | 29 ++++++ .../components/prosegur/manifest.json | 2 +- .../components/prosegur/services.yaml | 7 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/prosegur/common.py | 27 ------ tests/components/prosegur/conftest.py | 58 +++++++++++ .../prosegur/test_alarm_control_panel.py | 22 ++--- tests/components/prosegur/test_camera.py | 69 +++++++++++++ tests/components/prosegur/test_diagnostics.py | 21 ++++ tests/components/prosegur/test_init.py | 55 +++-------- 15 files changed, 317 insertions(+), 87 deletions(-) create mode 100644 homeassistant/components/prosegur/camera.py create mode 100644 homeassistant/components/prosegur/diagnostics.py create mode 100644 homeassistant/components/prosegur/services.yaml delete mode 100644 tests/components/prosegur/common.py create mode 100644 tests/components/prosegur/conftest.py create mode 100644 tests/components/prosegur/test_camera.py create mode 100644 tests/components/prosegur/test_diagnostics.py diff --git a/homeassistant/components/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py index 04f353e96b8a..9f594fc6dae7 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import aiohttp_client from .const import CONF_COUNTRY, DOMAIN -PLATFORMS = [Platform.ALARM_CONTROL_PANEL] +PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 133c182e2cc5..cfcb07773f5c 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -15,6 +15,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN @@ -59,6 +60,14 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self._attr_name = f"contract {self.contract}" self._attr_unique_id = self.contract + self._attr_device_info = DeviceInfo( + name="Prosegur Alarm", + manufacturer="Prosegur", + model="smart", + identifiers={(DOMAIN, self.contract)}, + configuration_url="https://smart.prosegur.com", + ) + async def async_update(self) -> None: """Update alarm status.""" diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py new file mode 100644 index 000000000000..40f8e18fb66c --- /dev/null +++ b/homeassistant/components/prosegur/camera.py @@ -0,0 +1,97 @@ +"""Support for Prosegur cameras.""" +from __future__ import annotations + +import logging + +from pyprosegur.auth import Auth +from pyprosegur.exceptions import ProsegurException +from pyprosegur.installation import Camera as InstallationCamera, Installation + +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) + +from . import DOMAIN +from .const import SERVICE_REQUEST_IMAGE + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Prosegur camera platform.""" + + platform = async_get_current_platform() + platform.async_register_entity_service( + SERVICE_REQUEST_IMAGE, + {}, + "async_request_image", + ) + + _installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id]) + + async_add_entities( + [ + ProsegurCamera(_installation, camera, hass.data[DOMAIN][entry.entry_id]) + for camera in _installation.cameras + ], + update_before_add=True, + ) + + +class ProsegurCamera(Camera): + """Representation of a Smart Prosegur Camera.""" + + def __init__( + self, installation: Installation, camera: InstallationCamera, auth: Auth + ) -> None: + """Initialize Prosegur Camera component.""" + Camera.__init__(self) + + self._installation = installation + self._camera = camera + self._auth = auth + self._attr_name = camera.description + self._attr_unique_id = f"{self._installation.contract} {camera.id}" + + self._attr_device_info = DeviceInfo( + name=self._camera.description, + manufacturer="Prosegur", + model="smart camera", + identifiers={(DOMAIN, self._installation.contract)}, + configuration_url="https://smart.prosegur.com", + ) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + + try: + _LOGGER.debug("Get image for %s", self._camera.description) + return await self._installation.get_image(self._auth, self._camera.id) + + except ProsegurException as err: + _LOGGER.error("Image %s doesn't exist: %s", self._camera.description, err) + + return None + + async def async_request_image(self): + """Request new image from the camera.""" + + try: + _LOGGER.debug("Request image for %s", self._camera.description) + await self._installation.request_image(self._auth, self._camera.id) + + except ProsegurException as err: + _LOGGER.error( + "Could not request image from camera %s: %s", + self._camera.description, + err, + ) diff --git a/homeassistant/components/prosegur/const.py b/homeassistant/components/prosegur/const.py index b066b320a174..3f5b86919708 100644 --- a/homeassistant/components/prosegur/const.py +++ b/homeassistant/components/prosegur/const.py @@ -3,3 +3,5 @@ DOMAIN = "prosegur" CONF_COUNTRY = "country" + +SERVICE_REQUEST_IMAGE = "request_image" diff --git a/homeassistant/components/prosegur/diagnostics.py b/homeassistant/components/prosegur/diagnostics.py new file mode 100644 index 000000000000..d24456983488 --- /dev/null +++ b/homeassistant/components/prosegur/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for Prosegur.""" +from __future__ import annotations + +from typing import Any + +from pyprosegur.installation import Installation + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {"description", "latitude", "longitude", "contractId", "address"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + installation = await Installation.retrieve(hass.data[DOMAIN][entry.entry_id]) + + activity = await installation.activity(hass.data[DOMAIN][entry.entry_id]) + + return { + "installation": async_redact_data(installation.data, TO_REDACT), + "activity": activity, + } diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json index 1827939d097a..d5081a82dbfc 100644 --- a/homeassistant/components/prosegur/manifest.json +++ b/homeassistant/components/prosegur/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/prosegur", "iot_class": "cloud_polling", "loggers": ["pyprosegur"], - "requirements": ["pyprosegur==0.0.5"] + "requirements": ["pyprosegur==0.0.8"] } diff --git a/homeassistant/components/prosegur/services.yaml b/homeassistant/components/prosegur/services.yaml new file mode 100644 index 000000000000..0db63cb7adf8 --- /dev/null +++ b/homeassistant/components/prosegur/services.yaml @@ -0,0 +1,7 @@ +request_image: + name: Request Camera image + description: Request a new image from a Prosegur Camera + target: + entity: + domain: camera + integration: prosegur diff --git a/requirements_all.txt b/requirements_all.txt index 10dfef7810a7..6f8143b0547d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1884,7 +1884,7 @@ pypoint==2.3.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.5 +pyprosegur==0.0.8 # homeassistant.components.prusalink pyprusalink==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b67a5887297..33aa5f39ca2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1364,7 +1364,7 @@ pypoint==2.3.0 pyprof2calltree==1.4.5 # homeassistant.components.prosegur -pyprosegur==0.0.5 +pyprosegur==0.0.8 # homeassistant.components.prusalink pyprusalink==1.1.0 diff --git a/tests/components/prosegur/common.py b/tests/components/prosegur/common.py deleted file mode 100644 index bed9d987cebf..000000000000 --- a/tests/components/prosegur/common.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Common methods used across tests for Prosegur.""" -from homeassistant.components.prosegur import DOMAIN as PROSEGUR_DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry - -CONTRACT = "1234abcd" - - -async def setup_platform(hass): - """Set up the Prosegur platform.""" - mock_entry = MockConfigEntry( - domain=PROSEGUR_DOMAIN, - data={ - "contract": "1234abcd", - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - "country": "PT", - }, - ) - mock_entry.add_to_hass(hass) - - assert await async_setup_component(hass, PROSEGUR_DOMAIN, {}) - await hass.async_block_till_done() - - return mock_entry diff --git a/tests/components/prosegur/conftest.py b/tests/components/prosegur/conftest.py new file mode 100644 index 000000000000..ea906fdcbff4 --- /dev/null +++ b/tests/components/prosegur/conftest.py @@ -0,0 +1,58 @@ +"""Define test fixtures for Prosegur.""" +from unittest.mock import AsyncMock, patch + +from pyprosegur.installation import Camera +import pytest + +from homeassistant.components.prosegur import DOMAIN as PROSEGUR_DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +CONTRACT = "1234abcd" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=PROSEGUR_DOMAIN, + data={ + "contract": CONTRACT, + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + "country": "PT", + }, + ) + + +@pytest.fixture +def mock_install() -> AsyncMock: + """Return the mocked alarm install.""" + install = AsyncMock() + install.contract = CONTRACT + install.cameras = [Camera("1", "test_cam")] + install.get_image = AsyncMock(return_value=b"ABC") + install.request_image = AsyncMock() + + install.data = {"contract": CONTRACT} + install.activity = AsyncMock(return_value={"event": "armed"}) + + return install + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_install: AsyncMock +) -> MockConfigEntry: + """Set up the Prosegur integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "pyprosegur.installation.Installation.retrieve", return_value=mock_install + ), patch("pyprosegur.auth.Auth.login", return_value=AsyncMock()): + 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/prosegur/test_alarm_control_panel.py b/tests/components/prosegur/test_alarm_control_panel.py index dce5e8d3c4e9..51086e74b00e 100644 --- a/tests/components/prosegur/test_alarm_control_panel.py +++ b/tests/components/prosegur/test_alarm_control_panel.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_component, entity_registry as er -from .common import CONTRACT, setup_platform +from .conftest import CONTRACT PROSEGUR_ALARM_ENTITY = f"alarm_control_panel.contract_{CONTRACT}" @@ -38,17 +38,17 @@ def mock_status(request): """Mock the status of the alarm.""" install = AsyncMock() - install.contract = "123" - install.installationId = "1234abcd" + install.contract = CONTRACT install.status = request.param with patch("pyprosegur.installation.Installation.retrieve", return_value=install): yield -async def test_entity_registry(hass: HomeAssistant, mock_auth, mock_status) -> None: +async def test_entity_registry( + hass: HomeAssistant, init_integration, mock_auth, mock_status +) -> None: """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass) entity_registry = er.async_get(hass) entry = entity_registry.async_get(PROSEGUR_ALARM_ENTITY) @@ -59,11 +59,13 @@ async def test_entity_registry(hass: HomeAssistant, mock_auth, mock_status) -> N state = hass.states.get(PROSEGUR_ALARM_ENTITY) - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "contract 1234abcd" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == f"contract {CONTRACT}" assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 -async def test_connection_error(hass: HomeAssistant, mock_auth) -> None: +async def test_connection_error( + hass: HomeAssistant, init_integration, mock_auth, mock_config_entry +) -> None: """Test the alarm control panel when connection can't be made to the cloud service.""" install = AsyncMock() @@ -73,8 +75,6 @@ async def test_connection_error(hass: HomeAssistant, mock_auth) -> None: install.status = Status.ARMED with patch("pyprosegur.installation.Installation.retrieve", return_value=install): - await setup_platform(hass) - await hass.async_block_till_done() with patch( @@ -95,7 +95,7 @@ async def test_connection_error(hass: HomeAssistant, mock_auth) -> None: ], ) async def test_arm( - hass: HomeAssistant, mock_auth, code, alarm_service, alarm_state + hass: HomeAssistant, init_integration, mock_auth, code, alarm_service, alarm_state ) -> None: """Test the alarm control panel can be set to away.""" @@ -106,8 +106,6 @@ async def test_arm( install.status = code with patch("pyprosegur.installation.Installation.retrieve", return_value=install): - await setup_platform(hass) - await hass.services.async_call( ALARM_DOMAIN, alarm_service, diff --git a/tests/components/prosegur/test_camera.py b/tests/components/prosegur/test_camera.py new file mode 100644 index 000000000000..75e4cbbc7738 --- /dev/null +++ b/tests/components/prosegur/test_camera.py @@ -0,0 +1,69 @@ +"""The camera tests for the prosegur platform.""" +import logging +from unittest.mock import AsyncMock + +from pyprosegur.exceptions import ProsegurException +import pytest + +from homeassistant.components import camera +from homeassistant.components.camera import Image +from homeassistant.components.prosegur.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.exceptions import HomeAssistantError + + +async def test_camera(hass, init_integration): + """Test prosegur get_image.""" + + image = await camera.async_get_image(hass, "camera.test_cam") + + assert image == Image(content_type="image/jpeg", content=b"ABC") + + +async def test_camera_fail(hass, init_integration, mock_install, caplog): + """Test prosegur get_image fails.""" + + mock_install.get_image = AsyncMock( + return_value=b"ABC", side_effect=ProsegurException() + ) + + with caplog.at_level(logging.ERROR, logger="homeassistant.components.prosegur"): + try: + await camera.async_get_image(hass, "camera.test_cam") + except HomeAssistantError as exc: + assert str(exc) == "Unable to get image" + else: + assert pytest.fail() + + assert "Image test_cam doesn't exist" in caplog.text + + +async def test_request_image(hass, init_integration, mock_install): + """Test the camera request image service.""" + + await hass.services.async_call( + DOMAIN, + "request_image", + {ATTR_ENTITY_ID: "camera.test_cam"}, + ) + await hass.async_block_till_done() + + assert mock_install.request_image.called + + +async def test_request_image_fail(hass, init_integration, mock_install, caplog): + """Test the camera request image service fails.""" + + mock_install.request_image = AsyncMock(side_effect=ProsegurException()) + + with caplog.at_level(logging.ERROR, logger="homeassistant.components.prosegur"): + await hass.services.async_call( + DOMAIN, + "request_image", + {ATTR_ENTITY_ID: "camera.test_cam"}, + ) + await hass.async_block_till_done() + + assert mock_install.request_image.called + + assert "Could not request image from camera test_cam" in caplog.text diff --git a/tests/components/prosegur/test_diagnostics.py b/tests/components/prosegur/test_diagnostics.py new file mode 100644 index 000000000000..85377833a74f --- /dev/null +++ b/tests/components/prosegur/test_diagnostics.py @@ -0,0 +1,21 @@ +"""Test Prosegur diagnostics.""" + +from unittest.mock import patch + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics(hass, hass_client, init_integration, mock_install): + """Test generating diagnostics for a config entry.""" + + with patch( + "pyprosegur.installation.Installation.retrieve", return_value=mock_install + ): + diag = await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) + + assert diag == { + "installation": {"contract": "1234abcd"}, + "activity": {"event": "armed"}, + } diff --git a/tests/components/prosegur/test_init.py b/tests/components/prosegur/test_init.py index 7f0373ea93d7..cdc7135cf1f5 100644 --- a/tests/components/prosegur/test_init.py +++ b/tests/components/prosegur/test_init.py @@ -1,12 +1,10 @@ """Tests prosegur setup.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from homeassistant.components.prosegur import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -17,59 +15,28 @@ from tests.test_util.aiohttp import AiohttpClientMocker ConnectionError, ], ) -async def test_setup_entry_fail_retrieve(hass: HomeAssistant, error) -> None: +async def test_setup_entry_fail_retrieve( + hass: HomeAssistant, mock_config_entry, error +) -> None: """Test loading the Prosegur entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "test-username", - "password": "test-password", - "country": "PT", - "contract": "xpto", - }, - ) - config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) with patch( "pyprosegur.auth.Auth.login", side_effect=error, ): - assert not await hass.config_entries.async_setup(config_entry.entry_id) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() async def test_unload_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration, + mock_config_entry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test unloading the Prosegur entry.""" - aioclient_mock.post( - "https://smart.prosegur.com/smart-server/ws/access/login", - json={"data": {"token": "123456789"}}, - ) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "username": "test-username", - "password": "test-password", - "country": "PT", - "contract": "xpto", - }, - ) - config_entry.add_to_hass(hass) - - install = MagicMock() - install.contract = "123" - - with patch( - "homeassistant.components.prosegur.config_flow.Installation.retrieve", - return_value=install, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - - await hass.async_block_till_done() - - assert await hass.config_entries.async_unload(config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) From d219e7c8b1a2d75cfd2f956b4afccacbe5cd6245 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 20:06:27 -0600 Subject: [PATCH 0059/1058] Bump yalexs-ble to 2.0.4 (#88798) changelog: https://github.com/bdraco/yalexs-ble/compare/v2.0.3...v2.0.4 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 718a6b571af4..dedfc9127a3a 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.2.7", "yalexs_ble==2.0.3"] + "requirements": ["yalexs==1.2.7", "yalexs_ble==2.0.4"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index b8d9ad3d16f3..e34ace05e154 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.0.3"] + "requirements": ["yalexs-ble==2.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6f8143b0547d..7d8fa102c8be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2670,13 +2670,13 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.3 +yalexs-ble==2.0.4 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.3 +yalexs_ble==2.0.4 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33aa5f39ca2e..51736e7e4cc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1895,13 +1895,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.3 +yalexs-ble==2.0.4 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.3 +yalexs_ble==2.0.4 # homeassistant.components.yeelight yeelight==0.7.10 From 480a495239f1f2e038fd75822d05f4b48331228b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 20:08:20 -0600 Subject: [PATCH 0060/1058] Fix unifiprotect discovery running at shutdown (#88802) * Fix unifiprotect discovery running at shutdown Move the discovery start into `async_setup` so we only start discovery once reguardless of how many config entries for unifiprotect they have (or how many times they reload). Always make discovery a background task so it does not get to block shutdown * missing decorator --- .../components/unifiprotect/__init__.py | 10 ++++-- .../components/unifiprotect/discovery.py | 14 +++++--- .../unifiprotect/test_config_flow.py | 36 +++++++++++++++---- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 4e659d39cc57..96d31872d0b0 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.issue_registry import IssueSeverity +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_ALLOW_EA, @@ -40,10 +41,15 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the UniFi Protect.""" + # Only start discovery once regardless of how many entries they have + async_start_discovery(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the UniFi Protect config entries.""" - - async_start_discovery(hass) protect = async_create_api_client(hass, entry) _LOGGER.debug("Connect to UniFi Protect") data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) diff --git a/homeassistant/components/unifiprotect/discovery.py b/homeassistant/components/unifiprotect/discovery.py index 1828687c0dd9..ea3730fa3e39 100644 --- a/homeassistant/components/unifiprotect/discovery.py +++ b/homeassistant/components/unifiprotect/discovery.py @@ -29,13 +29,19 @@ def async_start_discovery(hass: HomeAssistant) -> None: return domain_data[DISCOVERY] = True - async def _async_discovery(*_: Any) -> None: + async def _async_discovery() -> None: async_trigger_discovery(hass, await async_discover_devices()) - # Do not block startup since discovery takes 31s or more - hass.async_create_background_task(_async_discovery(), "unifiprotect-discovery") + @callback + def _async_start_background_discovery(*_: Any) -> None: + """Run discovery in the background.""" + hass.async_create_background_task(_async_discovery(), "unifiprotect-discovery") - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + # Do not block startup since discovery takes 31s or more + _async_start_background_discovery() + async_track_time_interval( + hass, _async_start_background_discovery, DISCOVERY_INTERVAL + ) async def async_discover_devices() -> list[UnifiDevice]: diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 1c348fc0086e..854109bee6de 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -71,7 +71,10 @@ async def test_form(hass: HomeAssistant, nvr: NVR) -> None: ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -93,6 +96,7 @@ async def test_form(hass: HomeAssistant, nvr: NVR) -> None: "verify_ssl": False, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_form_version_too_old(hass: HomeAssistant, old_nvr: NVR) -> None: @@ -214,7 +218,10 @@ async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None: with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", return_value=nvr, - ): + ), patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { @@ -225,6 +232,7 @@ async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None: assert result3["type"] == FlowResultType.ABORT assert result3["reason"] == "reauth_successful" + assert len(mock_setup.mock_calls) == 1 async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) -> None: @@ -332,7 +340,10 @@ async def test_discovered_by_unifi_discovery_direct_connect( ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -353,6 +364,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( "verify_ssl": True, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_discovered_by_unifi_discovery_direct_connect_updated( @@ -515,7 +527,10 @@ async def test_discovered_by_unifi_discovery(hass: HomeAssistant, nvr: NVR) -> N ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -536,6 +551,7 @@ async def test_discovered_by_unifi_discovery(hass: HomeAssistant, nvr: NVR) -> N "verify_ssl": False, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_discovered_by_unifi_discovery_partial( @@ -567,7 +583,10 @@ async def test_discovered_by_unifi_discovery_partial( ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -588,6 +607,7 @@ async def test_discovered_by_unifi_discovery_partial( "verify_ssl": False, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface( @@ -736,7 +756,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.unifiprotect.async_setup", + return_value=True, + ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -757,6 +780,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "verify_ssl": True, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup.mock_calls) == 1 async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver_no_result( From 4898d2296041f81493f694a311ea41e74892e68a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 20:14:54 -0600 Subject: [PATCH 0061/1058] Fix flux_led discovery running at shutdown (#88817) --- homeassistant/components/flux_led/__init__.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 7d7ef2d42bf6..86b73c762fb6 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -87,14 +87,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, STARTUP_SCAN_TIMEOUT ) + @callback + def _async_start_background_discovery(*_: Any) -> None: + """Run discovery in the background.""" + hass.async_create_background_task(_async_discovery(), "flux_led-discovery") + async def _async_discovery(*_: Any) -> None: async_trigger_discovery( hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT) ) async_trigger_discovery(hass, domain_data[FLUX_LED_DISCOVERY]) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_discovery) - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, _async_start_background_discovery + ) + async_track_time_interval( + hass, _async_start_background_discovery, DISCOVERY_INTERVAL + ) return True From f8934175cbe88df04070c7e6c020a322005e8126 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:01:02 -0600 Subject: [PATCH 0062/1058] Prevent integrations from retrying setup once shutdown has started (#88818) * Prevent integrations from retrying setup once shutdown has started * coverage --- homeassistant/config_entries.py | 4 ++++ tests/test_config_entries.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index df4cb6515283..bbea3f1d5f80 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -445,6 +445,10 @@ class ConfigEntry: async def setup_again(*_: Any) -> None: """Run setup again.""" + # Check again when we fire in case shutdown + # has started so we do not block shutdown + if hass.is_stopping: + return self._async_cancel_retry_setup = None await self.async_setup(hass, integration=integration, tries=tries) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index aecdc79da921..12b77aded8f9 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -29,6 +29,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util import dt +import homeassistant.util.dt as dt_util from .common import ( MockConfigEntry, @@ -999,6 +1000,27 @@ async def test_setup_retrying_during_unload_before_started(hass: HomeAssistant) ) +async def test_setup_does_not_retry_during_shutdown(hass: HomeAssistant) -> None: + """Test we do not retry when HASS is shutting down.""" + entry = MockConfigEntry(domain="test") + + mock_setup_entry = AsyncMock(side_effect=ConfigEntryNotReady) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, "config_flow.test", None) + + await entry.async_setup(hass) + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + assert len(mock_setup_entry.mock_calls) == 1 + + hass.state = CoreState.stopping + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_create_entry_options(hass: HomeAssistant) -> None: """Test a config entry being created with options.""" From 1d1c553d9be847c0e2fe25b93b963d1d9480f1fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:02:52 -0600 Subject: [PATCH 0063/1058] Avoid starting a bluetooth poll when Home Assistant is stopping (#88819) * Avoid starting a bluetooth poll when Home Assistant is stopping * tests --- .../bluetooth/active_update_coordinator.py | 2 + .../bluetooth/active_update_processor.py | 2 + .../test_active_update_coordinator.py | 57 ++++++++++++++++- .../bluetooth/test_active_update_processor.py | 64 ++++++++++++++++++- 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 78e713ce5e66..d5cf65d8724a 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -106,6 +106,8 @@ class ActiveBluetoothDataUpdateCoordinator( def needs_poll(self, service_info: BluetoothServiceInfoBleak) -> bool: """Return true if time to try and poll.""" + if self.hass.is_stopping: + return False poll_age: float | None = None if self._last_poll: poll_age = monotonic_time_coarse() - self._last_poll diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index b91ac2cbf4d6..aabc27ff14ea 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -99,6 +99,8 @@ class ActiveBluetoothProcessorCoordinator( def needs_poll(self, service_info: BluetoothServiceInfoBleak) -> bool: """Return true if time to try and poll.""" + if self.hass.is_stopping: + return False poll_age: float | None = None if self._last_poll: poll_age = monotonic_time_coarse() - self._last_poll diff --git a/tests/components/bluetooth/test_active_update_coordinator.py b/tests/components/bluetooth/test_active_update_coordinator.py index 26697219ae54..2686138d7248 100644 --- a/tests/components/bluetooth/test_active_update_coordinator.py +++ b/tests/components/bluetooth/test_active_update_coordinator.py @@ -19,7 +19,7 @@ from homeassistant.components.bluetooth.active_update_coordinator import ( _T, ActiveBluetoothDataUpdateCoordinator, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component @@ -395,3 +395,58 @@ async def test_polling_rejecting_the_first_time( cancel() unregister_listener() + + +async def test_no_polling_after_stop_event( + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + mock_bluetooth_adapters: None, +) -> None: + """Test we do not poll after the stop event.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + needs_poll_calls = 0 + + def _needs_poll( + service_info: BluetoothServiceInfoBleak, seconds_since_last_poll: float | None + ) -> bool: + nonlocal needs_poll_calls + needs_poll_calls += 1 + return True + + async def _poll_method(service_info: BluetoothServiceInfoBleak) -> dict[str, Any]: + return {"fake": "data"} + + coordinator = MyCoordinator( + hass=hass, + logger=_LOGGER, + address="aa:bb:cc:dd:ee:ff", + mode=BluetoothScanningMode.ACTIVE, + needs_poll_method=_needs_poll, + poll_method=_poll_method, + ) + assert coordinator.available is False # no data yet + + mock_listener = MagicMock() + unregister_listener = coordinator.async_add_listener(mock_listener) + + cancel = coordinator.async_start() + assert needs_poll_calls == 0 + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + await hass.async_block_till_done() + assert coordinator.passive_data == {"rssi": GENERIC_BLUETOOTH_SERVICE_INFO.rssi} + assert coordinator.data == {"fake": "data"} + + assert needs_poll_calls == 1 + + hass.state = CoreState.stopping + await hass.async_block_till_done() + assert needs_poll_calls == 1 + + # Should not generate a poll now + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + await hass.async_block_till_done() + assert needs_poll_calls == 1 + + cancel() + unregister_listener() diff --git a/tests/components/bluetooth/test_active_update_processor.py b/tests/components/bluetooth/test_active_update_processor.py index a8dec3cca27e..83ad809016a2 100644 --- a/tests/components/bluetooth/test_active_update_processor.py +++ b/tests/components/bluetooth/test_active_update_processor.py @@ -16,7 +16,7 @@ from homeassistant.components.bluetooth import ( from homeassistant.components.bluetooth.active_update_processor import ( ActiveBluetoothProcessorCoordinator, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component @@ -384,3 +384,65 @@ async def test_rate_limit( assert async_handle_update.mock_calls[-1] == call({"testdata": 1}) cancel() + + +async def test_no_polling_after_stop_event( + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + mock_bluetooth_adapters: None, +) -> None: + """Test we do not poll after the stop event.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + needs_poll_calls = 0 + + def _update_method(service_info: BluetoothServiceInfoBleak): + return {"testdata": 0} + + def _poll_needed(*args, **kwargs): + nonlocal needs_poll_calls + needs_poll_calls += 1 + return True + + async def _poll(*args, **kwargs): + return {"testdata": 1} + + coordinator = ActiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address="aa:bb:cc:dd:ee:ff", + mode=BluetoothScanningMode.ACTIVE, + update_method=_update_method, + needs_poll_method=_poll_needed, + poll_method=_poll, + ) + assert coordinator.available is False # no data yet + + processor = MagicMock() + coordinator.async_register_processor(processor) + async_handle_update = processor.async_handle_update + + cancel = coordinator.async_start() + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + await hass.async_block_till_done() + assert needs_poll_calls == 1 + + assert coordinator.available is True + + # async_handle_update should have been called twice + # The first time, it was passed the data from parsing the advertisement + # The second time, it was passed the data from polling + assert len(async_handle_update.mock_calls) == 2 + assert async_handle_update.mock_calls[0] == call({"testdata": 0}) + assert async_handle_update.mock_calls[1] == call({"testdata": 1}) + + hass.state = CoreState.stopping + await hass.async_block_till_done() + assert needs_poll_calls == 1 + + # Should not generate a poll now that CoreState is stopping + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + await hass.async_block_till_done() + assert needs_poll_calls == 1 + + cancel() From b5223e1196b46f4aba6a1b6275388524b4aefaaf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:36:18 -0600 Subject: [PATCH 0064/1058] Restore previous behavior of only waiting for new tasks at shutdown (#88740) * Restore previous behavior of only waiting for new tasks at shutdown * cleanup * do a swap instead * await canceled tasks * await canceled tasks * fix * not needed since we no longer clear * log it * reword * wait for airvisual * tests --- homeassistant/core.py | 38 +++++++++++++++ .../components/airvisual/test_config_flow.py | 1 + tests/test_core.py | 47 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/homeassistant/core.py b/homeassistant/core.py index 7268b7d8f242..7003b87ce677 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -38,6 +38,7 @@ from typing import ( ) from urllib.parse import urlparse +import async_timeout from typing_extensions import Self import voluptuous as vol import yarl @@ -711,6 +712,14 @@ class HomeAssistant: "Stopping Home Assistant before startup has completed may fail" ) + # Keep holding the reference to the tasks but do not allow them + # to block shutdown. Only tasks created after this point will + # be waited for. + running_tasks = self._tasks + # Avoid clearing here since we want the remove callbacks to fire + # and remove the tasks from the original set which is now running_tasks + self._tasks = set() + # Cancel all background tasks for task in self._background_tasks: self._tasks.add(task) @@ -749,6 +758,35 @@ class HomeAssistant: self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) + # Make a copy of running_tasks since a task can finish + # while we are awaiting canceled tasks to get their result + # which will result in the set size changing during iteration + for task in list(running_tasks): + if task.done(): + # Since we made a copy we need to check + # to see if the task finished while we + # were awaiting another task + continue + _LOGGER.warning( + "Task %s was still running after stage 2 shutdown; " + "Integrations should cancel non-critical tasks when receiving " + "the stop event to prevent delaying shutdown", + task, + ) + task.cancel() + try: + async with async_timeout.timeout(0.1): + await task + except asyncio.CancelledError: + pass + except asyncio.TimeoutError: + # Task may be shielded from cancellation. + _LOGGER.exception( + "Task %s could not be canceled during stage 3 shutdown", task + ) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception("Task %s error during stage 3 shutdown: %s", task, ex) + # Prevent run_callback_threadsafe from scheduling any additional # callbacks in the event loop as callbacks created on the futures # it returns will never run after the final `self.async_block_till_done` diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index 81c9fb818687..b07a17972f72 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -166,3 +166,4 @@ async def test_step_reauth( assert len(hass.config_entries.async_entries()) == 1 assert hass.config_entries.async_entries()[0].data[CONF_API_KEY] == new_api_key + await hass.async_block_till_done() diff --git a/tests/test_core.py b/tests/test_core.py index 4749daa0c0b4..eb81efae9200 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,6 +9,7 @@ import gc import logging import os from tempfile import TemporaryDirectory +import time from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch @@ -2003,3 +2004,49 @@ async def test_background_task(hass: HomeAssistant) -> None: await asyncio.sleep(0) await hass.async_stop() assert result.result() == ha.CoreState.stopping + + +async def test_shutdown_does_not_block_on_normal_tasks( + hass: HomeAssistant, +) -> None: + """Ensure shutdown does not block on normal tasks.""" + result = asyncio.Future() + unshielded_task = asyncio.sleep(10) + + async def test_task(): + try: + await unshielded_task + except asyncio.CancelledError: + result.set_result(hass.state) + + start = time.monotonic() + task = hass.async_create_task(test_task()) + await asyncio.sleep(0) + await hass.async_stop() + await asyncio.sleep(0) + assert result.done() + assert task.done() + assert time.monotonic() - start < 0.5 + + +async def test_shutdown_does_not_block_on_shielded_tasks( + hass: HomeAssistant, +) -> None: + """Ensure shutdown does not block on shielded tasks.""" + result = asyncio.Future() + shielded_task = asyncio.shield(asyncio.sleep(10)) + + async def test_task(): + try: + await shielded_task + except asyncio.CancelledError: + result.set_result(hass.state) + + start = time.monotonic() + task = hass.async_create_task(test_task()) + await asyncio.sleep(0) + await hass.async_stop() + await asyncio.sleep(0) + assert result.done() + assert task.done() + assert time.monotonic() - start < 0.5 From 0d25eef19ca68af4496bddb496834e3e5fb39fca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Feb 2023 22:42:17 -0500 Subject: [PATCH 0065/1058] Use a background task for LIFX discovery (#88820) --- homeassistant/components/lifx/__init__.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index b2265d81da9b..1bdbc618fdf6 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -17,10 +17,9 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STARTED, - EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval @@ -167,15 +166,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: We do not want the discovery task to block startup. """ - task = asyncio.create_task(discovery_manager.async_discovery()) - - @callback - def _async_stop(_: Event) -> None: - if not task.done(): - task.cancel() - - # Task must be shut down when home assistant is closing - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + hass.async_create_background_task( + discovery_manager.async_discovery(), "lifx-discovery" + ) # Let the system settle a bit before starting discovery # to reduce the risk we miss devices because the event From 33466cdddd164fb208a7e7bc3138f28b4844aab6 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 27 Feb 2023 07:45:53 +0100 Subject: [PATCH 0066/1058] Add climate state translations to Overkiz integration (#88809) Add climate translations --- .../atlantic_electrical_heater.py | 2 ++ ...er_with_adjustable_temperature_setpoint.py | 2 ++ .../atlantic_electrical_towel_dryer.py | 2 ++ .../atlantic_heat_recovery_ventilation.py | 2 ++ .../atlantic_pass_apc_heating_zone.py | 2 ++ .../climate_entities/somfy_thermostat.py | 3 ++ homeassistant/components/overkiz/strings.json | 28 +++++++++++++++++++ 7 files changed, 41 insertions(+) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py index bb095436054f..46a330c97cca 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import UnitOfTemperature +from ..const import DOMAIN from ..entity import OverkizEntity PRESET_COMFORT1 = "comfort-1" @@ -47,6 +48,7 @@ class AtlanticElectricalHeater(OverkizEntity, ClimateEntity): _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] _attr_supported_features = ClimateEntityFeature.PRESET_MODE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN @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 3b02523ec20f..d79d2fca6867 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 @@ -16,6 +16,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -70,6 +71,7 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( _attr_supported_features = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) + _attr_translation_key = DOMAIN 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 c9885ada4212..c8e4920a1139 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -43,6 +44,7 @@ class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN 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 7c469518f864..1da7c48f9eb8 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py @@ -13,6 +13,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -49,6 +50,7 @@ class AtlanticHeatRecoveryVentilation(OverkizEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.FAN_MODE ) + _attr_translation_key = DOMAIN 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 e90edad1133c..b6835d93ebb0 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 @@ -17,6 +17,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -78,6 +79,7 @@ class AtlanticPassAPCHeatingZone(OverkizEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN 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 c3fd7cd964d1..8242fdc85768 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from ..const import DOMAIN from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity @@ -60,6 +61,8 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): ) _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + _attr_translation_key = DOMAIN + # Both min and max temp values have been retrieved from the Somfy Application. _attr_min_temp = 15.0 _attr_max_temp = 26.0 diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 5f4f3a046421..41405780124e 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -28,6 +28,34 @@ } }, "entity": { + "climate": { + "overkiz": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "Auto", + "comfort-1": "Comfort 1", + "comfort-2": "Comfort 2", + "drying": "Drying", + "external": "External", + "freeze": "Freeze", + "frost_protection": "Frost protection", + "manual": "Manual", + "night": "Night", + "prog": "Prog" + } + }, + "fan_mode": { + "state": { + "away": "Away", + "bypass_boost": "Bypass boost", + "home_boost": "Home boost", + "kitchen_boost": "Kitchen boost" + } + } + } + } + }, "select": { "open_closed_pedestrian": { "state": { From 4fd7ca503f1062edcda0078b603a4d667185b10e Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 27 Feb 2023 08:09:45 +0100 Subject: [PATCH 0067/1058] Bump pyoverkiz to 1.7.6 (#88808) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 658f3f5c7f36..6ba7db46dd33 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.7.3"], + "requirements": ["pyoverkiz==1.7.6"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 7d8fa102c8be..2d47939f701f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1857,7 +1857,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.3 +pyoverkiz==1.7.6 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51736e7e4cc8..35eb4e939ea5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1340,7 +1340,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.3 +pyoverkiz==1.7.6 # homeassistant.components.openweathermap pyowm==3.2.0 From 66b33e1090251d3a07bc5b14436c4be99fa91a14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Feb 2023 08:41:23 +0100 Subject: [PATCH 0068/1058] Bump actions/checkout from 3.1.0 to 3.3.0 (#88824) Bumps [actions/checkout](https://github.com/actions/checkout) from 3.1.0 to 3.3.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3.1.0...v3.3.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 57c7425932b7..b45ffcd2d809 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1073,7 +1073,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.1.0 + uses: actions/checkout@v3.3.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.3.0 From b7846de31122d559fdc5feb74ff149dd46e5abeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Feb 2023 09:30:33 +0100 Subject: [PATCH 0069/1058] Bump actions/setup-python from 4.3.0 to 4.5.0 (#88823) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4.3.0 to 4.5.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4.3.0...v4.5.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 b45ffcd2d809..117b64022c10 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1076,7 +1076,7 @@ jobs: uses: actions/checkout@v3.3.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.3.0 + uses: actions/setup-python@v4.5.0 with: python-version: ${{ matrix.python-version }} check-latest: true From 10bf910f885a8d93a244a82768dc9e805d23155b Mon Sep 17 00:00:00 2001 From: StefanIacobLivisi <109964424+StefanIacobLivisi@users.noreply.github.com> Date: Mon, 27 Feb 2023 11:38:52 +0200 Subject: [PATCH 0070/1058] Add support for LIVISI climate devices (#86691) * Add support for LIVISI climate devices * Remove the reauthentication logic * Add support for LIVISI climate devices * Remove the reauthentication support * Code review follow-up * Update homeassistant/components/livisi/manifest.json Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/manifest.json Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Code review follow-up * Code Review Follow-up * Code Review Follow-up * Code review follow-up * Code review follow-up * Code review follow-up * Code review follow-up --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 4 + homeassistant/components/livisi/__init__.py | 5 +- homeassistant/components/livisi/climate.py | 215 ++++++++++++++++++ homeassistant/components/livisi/const.py | 7 +- .../components/livisi/coordinator.py | 42 +++- 5 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/livisi/climate.py diff --git a/.coveragerc b/.coveragerc index 7429966011a7..a06f4fa92d30 100644 --- a/.coveragerc +++ b/.coveragerc @@ -639,6 +639,10 @@ omit = homeassistant/components/linode/* homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* + homeassistant/components/livisi/__init__.py + homeassistant/components/livisi/climate.py + homeassistant/components/livisi/coordinator.py + homeassistant/components/livisi/switch.py homeassistant/components/llamalab_automate/notify.py homeassistant/components/logi_circle/__init__.py homeassistant/components/logi_circle/camera.py diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index e71c6bca660c..b8d8fdbfb099 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -8,14 +8,15 @@ from aiolivisi import AioLivisi from homeassistant import core 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 aiohttp_client, device_registry as dr -from .const import DOMAIN, SWITCH_PLATFORM +from .const import DOMAIN from .coordinator import LivisiDataUpdateCoordinator -PLATFORMS: Final = [SWITCH_PLATFORM] +PLATFORMS: Final = [Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py new file mode 100644 index 000000000000..d0bdbe64bf75 --- /dev/null +++ b/homeassistant/components/livisi/climate.py @@ -0,0 +1,215 @@ +"""Code to handle a Livisi Virtual Climate Control.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiolivisi.const import CAPABILITY_MAP + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DOMAIN, + LIVISI_REACHABILITY_CHANGE, + LIVISI_STATE_CHANGE, + LOGGER, + MAX_TEMPERATURE, + MIN_TEMPERATURE, + VRCC_DEVICE_TYPE, +) +from .coordinator import LivisiDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate device.""" + coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + @callback + def handle_coordinator_update() -> None: + """Add climate device.""" + shc_devices: list[dict[str, Any]] = coordinator.data + entities: list[ClimateEntity] = [] + for device in shc_devices: + if ( + device["type"] == VRCC_DEVICE_TYPE + and device["id"] not in coordinator.devices + ): + livisi_climate: ClimateEntity = create_entity( + config_entry, device, coordinator + ) + LOGGER.debug("Include device type: %s", device.get("type")) + coordinator.devices.add(device["id"]) + entities.append(livisi_climate) + async_add_entities(entities) + + config_entry.async_on_unload( + coordinator.async_add_listener(handle_coordinator_update) + ) + + +def create_entity( + config_entry: ConfigEntry, + device: dict[str, Any], + coordinator: LivisiDataUpdateCoordinator, +) -> ClimateEntity: + """Create Climate Entity.""" + capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] + room_id: str = device["location"] + room_name: str = coordinator.rooms[room_id] + livisi_climate = LivisiClimate( + config_entry, + coordinator, + unique_id=device["id"], + manufacturer=device["manufacturer"], + device_type=device["type"], + target_temperature_capability=capabilities["RoomSetpoint"], + temperature_capability=capabilities["RoomTemperature"], + humidity_capability=capabilities["RoomHumidity"], + room=room_name, + ) + return livisi_climate + + +class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntity): + """Represents the Livisi Climate.""" + + _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_mode = HVACMode.HEAT + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_high = MAX_TEMPERATURE + _attr_target_temperature_low = MIN_TEMPERATURE + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + unique_id: str, + manufacturer: str, + device_type: str, + target_temperature_capability: str, + temperature_capability: str, + humidity_capability: str, + room: str, + ) -> None: + """Initialize the Livisi Climate.""" + self.config_entry = config_entry + self._attr_unique_id = unique_id + self._target_temperature_capability = target_temperature_capability + self._temperature_capability = temperature_capability + self._humidity_capability = humidity_capability + self.aio_livisi = coordinator.aiolivisi + self._attr_available = False + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=manufacturer, + model=device_type, + name=room, + suggested_area=room, + via_device=(DOMAIN, config_entry.entry_id), + ) + super().__init__(coordinator) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + response = await self.aio_livisi.async_vrcc_set_temperature( + self._target_temperature_capability, + kwargs.get(ATTR_TEMPERATURE), + self.coordinator.is_avatar, + ) + if response is None: + self._attr_available = False + raise HomeAssistantError(f"Failed to turn off {self._attr_name}") + + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Do nothing as LIVISI devices do not support changing the hvac mode.""" + raise HomeAssistantError( + "This feature is not supported with the LIVISI climate devices" + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + target_temperature = await self.coordinator.async_get_vrcc_target_temperature( + self._target_temperature_capability + ) + temperature = await self.coordinator.async_get_vrcc_temperature( + self._temperature_capability + ) + humidity = await self.coordinator.async_get_vrcc_humidity( + self._humidity_capability + ) + if temperature is None: + self._attr_current_temperature = None + self._attr_available = False + else: + self._attr_target_temperature = target_temperature + self._attr_current_temperature = temperature + self._attr_current_humidity = humidity + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._target_temperature_capability}", + self.update_target_temperature, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._temperature_capability}", + self.update_temperature, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._humidity_capability}", + self.update_humidity, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", + self.update_reachability, + ) + ) + + @callback + def update_target_temperature(self, target_temperature: float) -> None: + """Update the target temperature of the climate device.""" + self._attr_target_temperature = target_temperature + self.async_write_ha_state() + + @callback + def update_temperature(self, current_temperature: float) -> None: + """Update the current temperature of the climate device.""" + self._attr_current_temperature = current_temperature + self.async_write_ha_state() + + @callback + def update_humidity(self, humidity: int) -> None: + """Update the humidity temperature of the climate device.""" + self._attr_current_humidity = humidity + self.async_write_ha_state() + + @callback + def update_reachability(self, is_reachable: bool) -> None: + """Update the reachability of the climate device.""" + self._attr_available = is_reachable + self.async_write_ha_state() diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index e6abc5118dea..684510cf7e32 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -7,12 +7,15 @@ DOMAIN = "livisi" CONF_HOST = "host" CONF_PASSWORD: Final = "password" +AVATAR = "Avatar" AVATAR_PORT: Final = 9090 CLASSIC_PORT: Final = 8080 DEVICE_POLLING_DELAY: Final = 60 LIVISI_STATE_CHANGE: Final = "livisi_state_change" LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" -SWITCH_PLATFORM: Final = "switch" - PSS_DEVICE_TYPE: Final = "PSS" +VRCC_DEVICE_TYPE: Final = "VRCC" + +MAX_TEMPERATURE: Final = 30.0 +MIN_TEMPERATURE: Final = 6.0 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 47a612274ac3..e6c29f7151e4 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -13,6 +13,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + AVATAR, AVATAR_PORT, CLASSIC_PORT, CONF_HOST, @@ -69,14 +70,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): livisi_connection_data=livisi_connection_data ) controller_data = await self.aiolivisi.async_get_controller() - if controller_data["controllerType"] == "Avatar": + if (controller_type := controller_data["controllerType"]) == AVATAR: self.port = AVATAR_PORT self.is_avatar = True else: self.port = CLASSIC_PORT self.is_avatar = False + self.controller_type = controller_type self.serial_number = controller_data["serialNumber"] - self.controller_type = controller_data["controllerType"] async def async_get_devices(self) -> list[dict[str, Any]]: """Set the discovered devices list.""" @@ -84,7 +85,7 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): async def async_get_pss_state(self, capability: str) -> bool | None: """Set the PSS state.""" - response: dict[str, Any] = await self.aiolivisi.async_get_device_state( + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( capability[1:] ) if response is None: @@ -92,6 +93,35 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): on_state = response["onState"] return on_state["value"] + async def async_get_vrcc_target_temperature(self, capability: str) -> float | None: + """Get the target temperature of the climate device.""" + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + capability[1:] + ) + if response is None: + return None + if self.is_avatar: + return response["setpointTemperature"]["value"] + return response["pointTemperature"]["value"] + + async def async_get_vrcc_temperature(self, capability: str) -> float | None: + """Get the temperature of the climate device.""" + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + capability[1:] + ) + if response is None: + return None + return response["temperature"]["value"] + + async def async_get_vrcc_humidity(self, capability: str) -> int | None: + """Get the humidity of the climate device.""" + response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + capability[1:] + ) + if response is None: + return None + return response["humidity"]["value"] + async def async_set_all_rooms(self) -> None: """Set the room list.""" response: list[dict[str, Any]] = await self.aiolivisi.async_get_all_rooms() @@ -108,6 +138,12 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): f"{LIVISI_STATE_CHANGE}_{event_data.source}", event_data.onState, ) + if event_data.vrccData is not None: + async_dispatcher_send( + self.hass, + f"{LIVISI_STATE_CHANGE}_{event_data.source}", + event_data.vrccData, + ) if event_data.isReachable is not None: async_dispatcher_send( self.hass, From ae3e8746f73a0b4ceb7bc97c5fcc16a356a1f119 Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Mon, 27 Feb 2023 05:19:29 -0500 Subject: [PATCH 0071/1058] Bump env_canada to 0.5.29 (#88821) --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 5ea67d3a0702..c2c2485d9480 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env_canada==0.5.28"] + "requirements": ["env_canada==0.5.29"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2d47939f701f..f2435e78b161 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -661,7 +661,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env_canada==0.5.28 +env_canada==0.5.29 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35eb4e939ea5..7e0a14a6a31a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -514,7 +514,7 @@ energyzero==0.3.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.5.28 +env_canada==0.5.29 # homeassistant.components.enphase_envoy envoy_reader==0.20.1 From 735000475a07604563c8ac51d7b037610e5b2923 Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Mon, 27 Feb 2023 11:29:46 +0100 Subject: [PATCH 0072/1058] Upgrade caldav to 1.2.0 (#88791) --- homeassistant/components/caldav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index e44251ed7c2f..16624f2af562 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.1.3"] + "requirements": ["caldav==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f2435e78b161..7db4120aa087 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -504,7 +504,7 @@ btsmarthub_devicelist==0.2.3 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.1.3 +caldav==1.2.0 # homeassistant.components.circuit circuit-webhook==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e0a14a6a31a..d13208a56ddc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -405,7 +405,7 @@ bthome-ble==2.5.2 buienradar==1.0.5 # homeassistant.components.caldav -caldav==1.1.3 +caldav==1.2.0 # homeassistant.components.co2signal co2signal==0.4.2 From fe8f3602ff57fd79712dd3239a8b808e745bdc75 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 11:46:55 +0100 Subject: [PATCH 0073/1058] Fix sensor unit conversion bug (#88825) * Fix sensor unit conversion bug * Ensure the correct unit is stored in the entity registry --- homeassistant/components/sensor/__init__.py | 43 +++++-- tests/components/sensor/test_init.py | 125 +++++++++++++++++++- 2 files changed, 156 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 75c37ab7b7d5..fd86024fbdf8 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -196,19 +196,30 @@ class SensorEntity(Entity): if self.unique_id is None or self.device_class is None: return registry = er.async_get(self.hass) + + # Bail out if the entity is not yet registered if not ( entity_id := registry.async_get_entity_id( platform.domain, platform.platform_name, self.unique_id ) ): + # Prime _sensor_option_unit_of_measurement to ensure the correct unit + # is stored in the entity registry. + self._sensor_option_unit_of_measurement = self._get_initial_suggested_unit() return + registry_entry = registry.async_get(entity_id) assert registry_entry + # Prime _sensor_option_unit_of_measurement to ensure the correct unit + # is stored in the entity registry. + self.registry_entry = registry_entry + self._async_read_entity_options() + # If the sensor has 'unit_of_measurement' in its sensor options, the user has # overridden the unit. - # If the sensor has 'sensor.private' in its entity options, it was added after - # automatic unit conversion was implemented. + # If the sensor has 'sensor.private' in its entity options, it already has a + # suggested_unit. registry_unit = registry_entry.unit_of_measurement if ( ( @@ -230,11 +241,14 @@ class SensorEntity(Entity): # Set suggested_unit_of_measurement to the old unit to enable automatic # conversion - registry.async_update_entity_options( + self.registry_entry = registry.async_update_entity_options( entity_id, f"{DOMAIN}.private", {"suggested_unit_of_measurement": registry_unit}, ) + # Update _sensor_option_unit_of_measurement to ensure the correct unit + # is stored in the entity registry. + self._async_read_entity_options() async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" @@ -305,12 +319,8 @@ class SensorEntity(Entity): return None - def get_initial_entity_options(self) -> er.EntityOptionsType | None: - """Return initial entity options. - - These will be stored in the entity registry the first time the entity is seen, - and then never updated. - """ + def _get_initial_suggested_unit(self) -> str | UndefinedType: + """Return the initial unit.""" # Unit suggested by the integration suggested_unit_of_measurement = self.suggested_unit_of_measurement @@ -321,6 +331,19 @@ class SensorEntity(Entity): ) if suggested_unit_of_measurement is None: + return UNDEFINED + + return suggested_unit_of_measurement + + def get_initial_entity_options(self) -> er.EntityOptionsType | None: + """Return initial entity options. + + These will be stored in the entity registry the first time the entity is seen, + and then never updated. + """ + suggested_unit_of_measurement = self._get_initial_suggested_unit() + + if suggested_unit_of_measurement is UNDEFINED: return None return { @@ -416,7 +439,7 @@ class SensorEntity(Entity): return self._sensor_option_unit_of_measurement # Second priority, for non registered entities: unit suggested by integration - if not self.registry_entry and self.suggested_unit_of_measurement: + if not self.unique_id and self.suggested_unit_of_measurement: return self.suggested_unit_of_measurement # Third priority: Legacy temperature conversion, which applies diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index b3c3f9262d71..7d96d51d5ca0 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -915,6 +915,7 @@ async def test_unit_conversion_priority( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit # Assert the automatic unit conversion is stored in the registry entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == automatic_unit assert entry.options == { "sensor.private": {"suggested_unit_of_measurement": automatic_unit} } @@ -930,6 +931,7 @@ async def test_unit_conversion_priority( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity2.entity_id) + assert entry.unit_of_measurement == suggested_unit assert entry.options == { "sensor.private": {"suggested_unit_of_measurement": suggested_unit} } @@ -1065,6 +1067,7 @@ async def test_unit_conversion_priority_precision( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == automatic_unit # Assert the automatic unit conversion is stored in the registry entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == automatic_unit assert entry.options == { "sensor": {"suggested_display_precision": 2}, "sensor.private": {"suggested_unit_of_measurement": automatic_unit}, @@ -1081,6 +1084,7 @@ async def test_unit_conversion_priority_precision( assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == suggested_unit # Assert the suggested unit is stored in the registry entry = entity_registry.async_get(entity2.entity_id) + assert entry.unit_of_measurement == suggested_unit assert entry.options == { "sensor": {"suggested_display_precision": 2}, "sensor.private": {"suggested_unit_of_measurement": suggested_unit}, @@ -1154,13 +1158,17 @@ async def test_unit_conversion_priority_suggested_unit_change( platform.init(empty=True) # Pre-register entities - entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + entry = entity_registry.async_get_or_create( + "sensor", "test", "very_unique", unit_of_measurement=original_unit + ) entity_registry.async_update_entity_options( entry.entity_id, "sensor.private", {"suggested_unit_of_measurement": original_unit}, ) - entry = entity_registry.async_get_or_create("sensor", "test", "very_unique_2") + entry = entity_registry.async_get_or_create( + "sensor", "test", "very_unique_2", unit_of_measurement=original_unit + ) entity_registry.async_update_entity_options( entry.entity_id, "sensor.private", @@ -1193,11 +1201,124 @@ async def test_unit_conversion_priority_suggested_unit_change( state = hass.states.get(entity0.entity_id) assert float(state.state) == pytest.approx(float(original_value)) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == original_unit + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": original_unit}, + } # Registered entity -> Follow suggested unit the first time the entity was seen state = hass.states.get(entity1.entity_id) assert float(state.state) == pytest.approx(float(original_value)) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == original_unit + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity1.entity_id) + assert entry.unit_of_measurement == original_unit + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": original_unit}, + } + + +@pytest.mark.parametrize( + ( + "native_unit_1", + "native_unit_2", + "suggested_unit", + "native_value", + "original_value", + "device_class", + ), + [ + # Distance + ( + UnitOfLength.KILOMETERS, + UnitOfLength.METERS, + UnitOfLength.KILOMETERS, + 1000000, + 1000, + SensorDeviceClass.DISTANCE, + ), + # Energy + ( + UnitOfEnergy.KILO_WATT_HOUR, + UnitOfEnergy.WATT_HOUR, + UnitOfEnergy.KILO_WATT_HOUR, + 1000000, + 1000, + SensorDeviceClass.ENERGY, + ), + ], +) +async def test_unit_conversion_priority_suggested_unit_change_2( + hass: HomeAssistant, + enable_custom_integrations: None, + native_unit_1, + native_unit_2, + suggested_unit, + native_value, + original_value, + device_class, +) -> None: + """Test priority of unit conversion.""" + + hass.config.units = METRIC_SYSTEM + + entity_registry = er.async_get(hass) + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + + # Pre-register entities + entity_registry.async_get_or_create( + "sensor", "test", "very_unique", unit_of_measurement=native_unit_1 + ) + entity_registry.async_get_or_create( + "sensor", "test", "very_unique_2", unit_of_measurement=native_unit_1 + ) + + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit_2, + native_value=str(native_value), + unique_id="very_unique", + ) + entity0 = platform.ENTITIES["0"] + + platform.ENTITIES["1"] = platform.MockSensor( + name="Test", + device_class=device_class, + native_unit_of_measurement=native_unit_2, + native_value=str(native_value), + suggested_unit_of_measurement=suggested_unit, + unique_id="very_unique_2", + ) + entity1 = platform.ENTITIES["1"] + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Registered entity -> Follow unit in entity registry + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit_1 + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == native_unit_1 + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": native_unit_1}, + } + + # Registered entity -> Follow unit in entity registry + state = hass.states.get(entity1.entity_id) + assert float(state.state) == pytest.approx(float(original_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == native_unit_1 + # Assert the suggested unit is stored in the registry + entry = entity_registry.async_get(entity0.entity_id) + assert entry.unit_of_measurement == native_unit_1 + assert entry.options == { + "sensor.private": {"suggested_unit_of_measurement": native_unit_1}, + } @pytest.mark.parametrize( From a8d587bc5379c0d3aad0174f20734b02fb39e78f Mon Sep 17 00:00:00 2001 From: landaisbenj <32144431+landaisbenj@users.noreply.github.com> Date: Mon, 27 Feb 2023 11:52:07 +0100 Subject: [PATCH 0074/1058] Add state_class to qbittorent sensors (#88829) Update Sensor.py on qbittorent integration Add stat class fonctionnality to sensor speed. --- 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 14bc0eb2ed99..e7b75954d52e 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_NAME, @@ -45,12 +46,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Down Speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, ), ) From b542f6b3acb4c59070c8485a045541f634d860af Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 27 Feb 2023 07:48:23 -0500 Subject: [PATCH 0075/1058] Bump aiosomecomfort to 0.0.10 (#88766) --- 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 02bb95c38f60..4b8e73e9fe72 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.8"] + "requirements": ["aiosomecomfort==0.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7db4120aa087..9bea267c26ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.8 +aiosomecomfort==0.0.10 # homeassistant.components.steamist aiosteamist==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d13208a56ddc..3e081f63a7c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -254,7 +254,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.8 +aiosomecomfort==0.0.10 # homeassistant.components.steamist aiosteamist==0.3.2 From b25f6e3ffc4f594d09999c9e78358e2feff27d1e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Feb 2023 13:54:56 +0100 Subject: [PATCH 0076/1058] Prepare for refactoring of MQTT related tests (#88557) * Update mqtt_mock * Tests manual_mqtt * Tests mqtt_json * Tests mqtt_room --- .../manual_mqtt/test_alarm_control_panel.py | 65 +++++++++---------- .../mqtt_json/test_device_tracker.py | 6 +- tests/components/mqtt_room/test_sensor.py | 8 +-- tests/conftest.py | 4 +- 4 files changed, 43 insertions(+), 40 deletions(-) diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 7ec876dbce59..8aaccad10569 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -33,13 +33,13 @@ from tests.common import ( async_fire_time_changed, ) from tests.components.alarm_control_panel import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClient CODE = "HELLO_CODE" async def test_fail_setup_without_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test for failing with no state topic.""" with assert_setup_component(0, alarm_control_panel.DOMAIN) as config: @@ -57,7 +57,7 @@ async def test_fail_setup_without_state_topic( async def test_fail_setup_without_command_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test failing with no command topic.""" with assert_setup_component(0, alarm_control_panel.DOMAIN): @@ -87,7 +87,7 @@ async def test_no_pending( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arm method.""" assert await async_setup_component( @@ -135,7 +135,7 @@ async def test_no_pending_when_code_not_req( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arm method.""" assert await async_setup_component( @@ -184,7 +184,7 @@ async def test_with_pending( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arm method.""" assert await async_setup_component( @@ -256,7 +256,7 @@ async def test_with_invalid_code( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Attempt to arm without a valid code.""" assert await async_setup_component( @@ -304,7 +304,7 @@ async def test_with_template_code( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Attempt to arm with a template-based code.""" assert await async_setup_component( @@ -353,7 +353,7 @@ async def test_with_specific_pending( hass: HomeAssistant, service, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arm method.""" assert await async_setup_component( @@ -395,7 +395,7 @@ async def test_with_specific_pending( async def test_trigger_no_pending( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test triggering when no pending submitted method.""" assert await async_setup_component( @@ -435,7 +435,7 @@ async def test_trigger_no_pending( async def test_trigger_with_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -483,7 +483,7 @@ async def test_trigger_with_delay( async def test_trigger_zero_trigger_time( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disabled trigger.""" assert await async_setup_component( @@ -513,7 +513,7 @@ async def test_trigger_zero_trigger_time( async def test_trigger_zero_trigger_time_with_pending( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disabled trigger.""" assert await async_setup_component( @@ -543,7 +543,7 @@ async def test_trigger_zero_trigger_time_with_pending( async def test_trigger_with_pending( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test arm home method.""" assert await async_setup_component( @@ -596,7 +596,7 @@ async def test_trigger_with_pending( async def test_trigger_with_disarm_after_trigger( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarm after trigger.""" assert await async_setup_component( @@ -636,7 +636,7 @@ async def test_trigger_with_disarm_after_trigger( async def test_trigger_with_zero_specific_trigger_time( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method.""" assert await async_setup_component( @@ -667,7 +667,7 @@ async def test_trigger_with_zero_specific_trigger_time( async def test_trigger_with_unused_zero_specific_trigger_time( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarm after trigger.""" assert await async_setup_component( @@ -708,7 +708,7 @@ async def test_trigger_with_unused_zero_specific_trigger_time( async def test_trigger_with_specific_trigger_time( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarm after trigger.""" assert await async_setup_component( @@ -748,7 +748,7 @@ async def test_trigger_with_specific_trigger_time( async def test_back_to_back_trigger_with_no_disarm_after_trigger( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test no disarm after back to back trigger.""" assert await async_setup_component( @@ -806,7 +806,7 @@ async def test_back_to_back_trigger_with_no_disarm_after_trigger( async def test_disarm_while_pending_trigger( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarming while pending state.""" assert await async_setup_component( @@ -849,7 +849,7 @@ async def test_disarm_while_pending_trigger( async def test_disarm_during_trigger_with_invalid_code( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarming while code is invalid.""" assert await async_setup_component( @@ -897,7 +897,7 @@ async def test_disarm_during_trigger_with_invalid_code( async def test_trigger_with_unused_specific_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -946,7 +946,7 @@ async def test_trigger_with_unused_specific_delay( async def test_trigger_with_specific_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -995,7 +995,7 @@ async def test_trigger_with_specific_delay( async def test_trigger_with_pending_and_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -1056,7 +1056,7 @@ async def test_trigger_with_pending_and_delay( async def test_trigger_with_pending_and_specific_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( @@ -1118,7 +1118,7 @@ async def test_trigger_with_pending_and_specific_delay( async def test_trigger_with_specific_pending( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test arm home method.""" assert await async_setup_component( @@ -1167,7 +1167,7 @@ async def test_trigger_with_specific_pending( async def test_trigger_with_no_disarm_after_trigger( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarm after trigger.""" assert await async_setup_component( @@ -1212,7 +1212,7 @@ async def test_trigger_with_no_disarm_after_trigger( async def test_arm_away_after_disabled_disarmed( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test pending state with and without zero trigger time.""" assert await async_setup_component( @@ -1278,7 +1278,7 @@ async def test_arm_away_after_disabled_disarmed( async def test_disarm_with_template_code( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Attempt to disarm with a valid or invalid template-based code.""" assert await async_setup_component( @@ -1332,7 +1332,7 @@ async def test_arm_via_command_topic( hass: HomeAssistant, config, expected_state, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock: MqttMockHAClient, ) -> None: """Test arming via command topic.""" command = config[8:].upper() @@ -1374,7 +1374,7 @@ async def test_arm_via_command_topic( async def test_disarm_pending_via_command_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test disarming pending alarm via command topic.""" assert await async_setup_component( @@ -1410,7 +1410,7 @@ async def test_disarm_pending_via_command_topic( async def test_state_changes_are_published_to_mqtt( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test publishing of MQTT messages when state changes.""" assert await async_setup_component( @@ -1431,7 +1431,6 @@ async def test_state_changes_are_published_to_mqtt( # Component should send disarmed alarm state on startup await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() mqtt_mock.async_publish.assert_called_once_with( "alarm/state", STATE_ALARM_DISARMED, 0, True ) diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py index c2fb33d6ae5d..2cc5299061ba 100644 --- a/tests/components/mqtt_json/test_device_tracker.py +++ b/tests/components/mqtt_json/test_device_tracker.py @@ -1,4 +1,5 @@ """The tests for the JSON MQTT device tracker platform.""" +from collections.abc import Generator import json import logging import os @@ -15,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient LOCATION_MESSAGE = { "longitude": 1.0, @@ -27,7 +29,9 @@ LOCATION_MESSAGE_INCOMPLETE = {"longitude": 2.0} @pytest.fixture(autouse=True) -async def setup_comp(hass, mqtt_mock_entry_with_yaml_config): +async def setup_comp( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> Generator[None, None, None]: """Initialize components.""" yaml_devices = hass.config.path(YAML_DEVICES) yield diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py index 0dd786a76891..999bcebd1744 100644 --- a/tests/components/mqtt_room/test_sensor.py +++ b/tests/components/mqtt_room/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClient DEVICE_ID = "123TESTMAC" NAME = "test_device" @@ -56,9 +56,7 @@ async def assert_distance(hass, distance): assert state.attributes.get("distance") == distance -async def test_room_update( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: +async def test_room_update(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test the updating between rooms.""" assert await async_setup_component( hass, @@ -96,7 +94,7 @@ async def test_room_update( async def test_unique_id_is_set( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: """Test the updating between rooms.""" unique_name = "my_unique_name_0123456789" diff --git a/tests/conftest.py b/tests/conftest.py index 9e7e6f107962..22701ee0e81f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -846,12 +846,14 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient, None, @pytest.fixture async def mqtt_mock( hass: HomeAssistant, + mock_hass_config: None, mqtt_client_mock: MqttMockPahoClient, mqtt_config_entry_data: dict[str, Any] | None, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> AsyncGenerator[MqttMockHAClient, None]: """Fixture to mock MQTT component.""" - return await mqtt_mock_entry_no_yaml_config() + with patch("homeassistant.components.mqtt.PLATFORMS", []): + return await mqtt_mock_entry_no_yaml_config() @asynccontextmanager From aeb6c4f07834dc9117127bca6f81f982cb840894 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 13:59:16 +0100 Subject: [PATCH 0077/1058] Tweak OTBR tests (#88839) --- tests/components/otbr/test_websocket_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 44baf6a2d94e..c071e760eb75 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -2,9 +2,9 @@ from unittest.mock import patch import pytest +import python_otbr_api from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from . import BASE_URL @@ -82,8 +82,8 @@ async def test_get_info_fetch_fails( await async_setup_component(hass, "otbr", {}) with patch( - "homeassistant.components.otbr.OTBRData.get_active_dataset_tlvs", - side_effect=HomeAssistantError, + "python_otbr_api.OTBR.get_active_dataset_tlvs", + side_effect=python_otbr_api.OTBRError, ): await websocket_client.send_json( { From 76819fbb231359fef98a9c74e08a93e0fd7a504c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Feb 2023 14:01:09 +0100 Subject: [PATCH 0078/1058] Add missing mock in brunt config flow tests (#88834) --- tests/components/brunt/conftest.py | 14 ++++++++++++++ tests/components/brunt/test_config_flow.py | 11 +++++------ 2 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 tests/components/brunt/conftest.py diff --git a/tests/components/brunt/conftest.py b/tests/components/brunt/conftest.py new file mode 100644 index 000000000000..8ae0bbaf317c --- /dev/null +++ b/tests/components/brunt/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for brunt 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.brunt.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/brunt/test_config_flow.py b/tests/components/brunt/test_config_flow.py index 93fd06e30d68..c8b0a3955c82 100644 --- a/tests/components/brunt/test_config_flow.py +++ b/tests/components/brunt/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Brunt config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientResponseError from aiohttp.client_exceptions import ServerDisconnectedError @@ -14,8 +14,10 @@ from tests.common import MockConfigEntry CONFIG = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"} +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: + +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}, data=None @@ -26,10 +28,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.brunt.config_flow.BruntClientAsync.async_login", return_value=None, - ), patch( - "homeassistant.components.brunt.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, From 5cc9e7feddcea2f3508e707c5296b239c17c530e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Feb 2023 14:01:53 +0100 Subject: [PATCH 0079/1058] Add missing mock in cert_expiry config flow tests (#88835) --- tests/components/cert_expiry/conftest.py | 14 ++++++++++++++ tests/components/cert_expiry/test_config_flow.py | 10 ++++------ 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 tests/components/cert_expiry/conftest.py diff --git a/tests/components/cert_expiry/conftest.py b/tests/components/cert_expiry/conftest.py new file mode 100644 index 000000000000..0a3f5420f60a --- /dev/null +++ b/tests/components/cert_expiry/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for cert_expiry 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.cert_expiry.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index ae493133d121..52985da00146 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -3,6 +3,8 @@ import socket import ssl from unittest.mock import patch +import pytest + from homeassistant import config_entries, data_entry_flow from homeassistant.components.cert_expiry.const import DEFAULT_PORT, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT @@ -13,6 +15,8 @@ from .helpers import future_timestamp from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_user(hass: HomeAssistant) -> None: """Test user config.""" @@ -34,9 +38,6 @@ async def test_user(hass: HomeAssistant) -> None: assert result["data"][CONF_PORT] == PORT assert result["result"].unique_id == f"{HOST}:{PORT}" - with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): - await hass.async_block_till_done() - async def test_user_with_bad_cert(hass: HomeAssistant) -> None: """Test user config with bad certificate.""" @@ -60,9 +61,6 @@ async def test_user_with_bad_cert(hass: HomeAssistant) -> None: assert result["data"][CONF_PORT] == PORT assert result["result"].unique_id == f"{HOST}:{PORT}" - with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): - await hass.async_block_till_done() - async def test_import_host_only(hass: HomeAssistant) -> None: """Test import with host only.""" From 198ebaff6eebd1ac1b2e1f7e9c4cfd27e192f595 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Feb 2023 14:03:51 +0100 Subject: [PATCH 0080/1058] Add missing mock in abode config flow tests (#88828) --- tests/components/abode/conftest.py | 12 ++++++++++++ tests/components/abode/test_config_flow.py | 3 +++ 2 files changed, 15 insertions(+) diff --git a/tests/components/abode/conftest.py b/tests/components/abode/conftest.py index 42b86f88e87f..1f9ff37ecf18 100644 --- a/tests/components/abode/conftest.py +++ b/tests/components/abode/conftest.py @@ -1,4 +1,7 @@ """Configuration for Abode tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + from jaraco.abode.helpers import urls as URL import pytest @@ -6,6 +9,15 @@ from tests.common import load_fixture from tests.components.light.conftest import mock_light_profiles # noqa: F401 +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.abode.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) def requests_mock_fixture(requests_mock) -> None: """Fixture to provide a requests mocker.""" diff --git a/tests/components/abode/test_config_flow.py b/tests/components/abode/test_config_flow.py index 5982a02c59d1..7619cf9325d9 100644 --- a/tests/components/abode/test_config_flow.py +++ b/tests/components/abode/test_config_flow.py @@ -6,6 +6,7 @@ from jaraco.abode.exceptions import ( AuthenticationException as AbodeAuthenticationException, ) from jaraco.abode.helpers.errors import MFA_CODE_REQUIRED +import pytest from requests.exceptions import ConnectTimeout from homeassistant import data_entry_flow @@ -17,6 +18,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" From 2c2489284bd0d427c7e515add9278c7139c3733e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 15:29:14 +0100 Subject: [PATCH 0081/1058] Catch CancelledError when setting up components (#88635) * Catch CancelledError when setting up components * Catch CancelledError when setting up components * Also catch SystemExit --- homeassistant/config_entries.py | 3 ++- homeassistant/setup.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index bbea3f1d5f80..94f2bab75acb 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -463,7 +463,8 @@ class ConfigEntry: await self._async_process_on_unload() return - except Exception: # pylint: disable=broad-except + # pylint: disable-next=broad-except + except (asyncio.CancelledError, SystemExit, Exception): _LOGGER.exception( "Error setting up entry %s for %s", self.title, integration.domain ) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 9740d338eff6..2377f47d7e91 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -264,7 +264,8 @@ async def _async_setup_component( SLOW_SETUP_MAX_WAIT, ) return False - except Exception: # pylint: disable=broad-except + # pylint: disable-next=broad-except + except (asyncio.CancelledError, SystemExit, Exception): _LOGGER.exception("Error during setup of component %s", domain) async_notify_setup_error(hass, domain, integration.documentation) return False From db1dd16ab04a4718af01040d947239f2707f9670 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 15:30:04 +0100 Subject: [PATCH 0082/1058] Add thread user flow (#88842) --- .../components/thread/config_flow.py | 21 +++++++++++------ tests/components/thread/test_config_flow.py | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index c6f151b8e63d..070378b34292 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -13,16 +13,23 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo - ) -> FlowResult: - """Set up because the user has border routers.""" - await self._async_handle_discovery_without_unique_id() - return self.async_create_entry(title="Thread", data={}) - async def async_step_import( self, import_data: dict[str, str] | None = None ) -> FlowResult: """Set up by import from async_setup.""" await self._async_handle_discovery_without_unique_id() return self.async_create_entry(title="Thread", data={}) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Set up by import from async_setup.""" + await self._async_handle_discovery_without_unique_id() + return self.async_create_entry(title="Thread", data={}) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Set up because the user has border routers.""" + await self._async_handle_discovery_without_unique_id() + return self.async_create_entry(title="Thread", data={}) diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index 5f19f233e3f6..a514760212b2 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -78,6 +78,29 @@ async def test_import_then_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 0 +async def test_user(hass: HomeAssistant) -> None: + """Test the user flow.""" + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Thread" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(thread.DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == {} + assert config_entry.title == "Thread" + assert config_entry.unique_id is None + + async def test_zeroconf(hass: HomeAssistant) -> None: """Test the zeroconf flow.""" with patch( From 2dcc2f88cc1061d7c2d2f825760c7978caf02243 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 27 Feb 2023 09:57:26 -0500 Subject: [PATCH 0083/1058] Use snapshots in blueprint import tests (#88843) --- .../blueprint/snapshots/test_importer.ambr | 210 ++++++++++++++++++ tests/components/blueprint/test_importer.py | 95 +------- 2 files changed, 216 insertions(+), 89 deletions(-) create mode 100644 tests/components/blueprint/snapshots/test_importer.ambr diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr new file mode 100644 index 000000000000..1401a8f1741e --- /dev/null +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -0,0 +1,210 @@ +# serializer version: 1 +# name: test_extract_blueprint_from_community_topic + OrderedDict({ + 'remote': OrderedDict({ + 'name': 'Remote', + 'description': 'IKEA remote to use', + 'selector': dict({ + 'device': OrderedDict({ + 'integration': 'zha', + 'manufacturer': 'IKEA of Sweden', + 'model': 'TRADFRI remote control', + 'multiple': False, + }), + }), + }), + 'light': OrderedDict({ + 'name': 'Light(s)', + 'description': 'The light(s) to control', + 'selector': dict({ + 'target': OrderedDict({ + 'entity': OrderedDict({ + 'domain': 'light', + }), + }), + }), + }), + 'force_brightness': OrderedDict({ + 'name': 'Force turn on brightness', + 'description': ''' + Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. + + ''', + 'default': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'brightness': OrderedDict({ + 'name': 'Brightness', + 'description': 'Brightness of the light(s) when turning on', + 'default': 50, + 'selector': dict({ + 'number': OrderedDict({ + 'min': 0.0, + 'max': 100.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '%', + }), + }), + }), + 'button_left_short': OrderedDict({ + 'name': 'Left button - short press', + 'description': 'Action to run on short left button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_left_long': OrderedDict({ + 'name': 'Left button - long press', + 'description': 'Action to run on long left button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_short': OrderedDict({ + 'name': 'Right button - short press', + 'description': 'Action to run on short right button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_long': OrderedDict({ + 'name': 'Right button - long press', + 'description': 'Action to run on long right button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + }) +# --- +# name: test_fetch_blueprint_from_community_url + OrderedDict({ + 'remote': OrderedDict({ + 'name': 'Remote', + 'description': 'IKEA remote to use', + 'selector': dict({ + 'device': OrderedDict({ + 'integration': 'zha', + 'manufacturer': 'IKEA of Sweden', + 'model': 'TRADFRI remote control', + 'multiple': False, + }), + }), + }), + 'light': OrderedDict({ + 'name': 'Light(s)', + 'description': 'The light(s) to control', + 'selector': dict({ + 'target': OrderedDict({ + 'entity': OrderedDict({ + 'domain': 'light', + }), + }), + }), + }), + 'force_brightness': OrderedDict({ + 'name': 'Force turn on brightness', + 'description': ''' + Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. + + ''', + 'default': False, + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'brightness': OrderedDict({ + 'name': 'Brightness', + 'description': 'Brightness of the light(s) when turning on', + 'default': 50, + 'selector': dict({ + 'number': OrderedDict({ + 'min': 0.0, + 'max': 100.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '%', + }), + }), + }), + 'button_left_short': OrderedDict({ + 'name': 'Left button - short press', + 'description': 'Action to run on short left button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_left_long': OrderedDict({ + 'name': 'Left button - long press', + 'description': 'Action to run on long left button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_short': OrderedDict({ + 'name': 'Right button - short press', + 'description': 'Action to run on short right button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_long': OrderedDict({ + 'name': 'Right button - long press', + 'description': 'Action to run on long right button press', + 'default': NodeListClass([ + ]), + 'selector': dict({ + 'action': dict({ + }), + }), + }), + }) +# --- +# name: test_fetch_blueprint_from_github_gist_url + OrderedDict({ + 'motion_entity': OrderedDict({ + 'name': 'Motion Sensor', + 'selector': dict({ + 'entity': OrderedDict({ + 'domain': 'binary_sensor', + 'device_class': 'motion', + 'multiple': False, + }), + }), + }), + 'light_entity': OrderedDict({ + 'name': 'Light', + 'selector': dict({ + 'entity': OrderedDict({ + 'domain': 'light', + 'multiple': False, + }), + }), + }), + }) +# --- diff --git a/tests/components/blueprint/test_importer.py b/tests/components/blueprint/test_importer.py index 8fd73db024cd..cdec562b99ff 100644 --- a/tests/components/blueprint/test_importer.py +++ b/tests/components/blueprint/test_importer.py @@ -18,74 +18,6 @@ def community_post(): return load_fixture("blueprint/community_post.json") -COMMUNITY_POST_INPUTS = { - "remote": { - "name": "Remote", - "description": "IKEA remote to use", - "selector": { - "device": { - "integration": "zha", - "manufacturer": "IKEA of Sweden", - "model": "TRADFRI remote control", - "multiple": False, - } - }, - }, - "light": { - "name": "Light(s)", - "description": "The light(s) to control", - "selector": {"target": {"entity": {"domain": "light"}}}, - }, - "force_brightness": { - "name": "Force turn on brightness", - "description": ( - 'Force the brightness to the set level below, when the "on" button on the' - " remote is pushed and lights turn on.\n" - ), - "default": False, - "selector": {"boolean": {}}, - }, - "brightness": { - "name": "Brightness", - "description": "Brightness of the light(s) when turning on", - "default": 50, - "selector": { - "number": { - "min": 0.0, - "max": 100.0, - "mode": "slider", - "step": 1.0, - "unit_of_measurement": "%", - } - }, - }, - "button_left_short": { - "name": "Left button - short press", - "description": "Action to run on short left button press", - "default": [], - "selector": {"action": {}}, - }, - "button_left_long": { - "name": "Left button - long press", - "description": "Action to run on long left button press", - "default": [], - "selector": {"action": {}}, - }, - "button_right_short": { - "name": "Right button - short press", - "description": "Action to run on short right button press", - "default": [], - "selector": {"action": {}}, - }, - "button_right_long": { - "name": "Right button - long press", - "description": "Action to run on long right button press", - "default": [], - "selector": {"action": {}}, - }, -} - - def test_get_community_post_import_url() -> None: """Test variations of generating import forum url.""" assert ( @@ -120,14 +52,14 @@ def test_get_github_import_url() -> None: ) -def test_extract_blueprint_from_community_topic(community_post) -> None: +def test_extract_blueprint_from_community_topic(community_post, snapshot) -> None: """Test extracting blueprint.""" imported_blueprint = importer._extract_blueprint_from_community_topic( "http://example.com", json.loads(community_post) ) assert imported_blueprint is not None assert imported_blueprint.blueprint.domain == "automation" - assert imported_blueprint.blueprint.inputs == COMMUNITY_POST_INPUTS + assert imported_blueprint.blueprint.inputs == snapshot def test_extract_blueprint_from_community_topic_invalid_yaml() -> None: @@ -161,7 +93,7 @@ def test_extract_blueprint_from_community_topic_wrong_lang() -> None: async def test_fetch_blueprint_from_community_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, community_post + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, community_post, snapshot ) -> None: """Test fetching blueprint from url.""" aioclient_mock.get( @@ -172,7 +104,7 @@ async def test_fetch_blueprint_from_community_url( ) assert isinstance(imported_blueprint, importer.ImportedBlueprint) assert imported_blueprint.blueprint.domain == "automation" - assert imported_blueprint.blueprint.inputs == COMMUNITY_POST_INPUTS + assert imported_blueprint.blueprint.inputs == snapshot assert ( imported_blueprint.suggested_filename == "frenck/zha-ikea-five-button-remote-for-lights" @@ -215,7 +147,7 @@ async def test_fetch_blueprint_from_github_url( async def test_fetch_blueprint_from_github_gist_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, snapshot ) -> None: """Test fetching blueprint from url.""" aioclient_mock.get( @@ -227,21 +159,6 @@ async def test_fetch_blueprint_from_github_gist_url( imported_blueprint = await importer.fetch_blueprint_from_url(hass, url) assert isinstance(imported_blueprint, importer.ImportedBlueprint) assert imported_blueprint.blueprint.domain == "automation" - assert imported_blueprint.blueprint.inputs == { - "motion_entity": { - "name": "Motion Sensor", - "selector": { - "entity": { - "domain": "binary_sensor", - "device_class": "motion", - "multiple": False, - } - }, - }, - "light_entity": { - "name": "Light", - "selector": {"entity": {"domain": "light", "multiple": False}}, - }, - } + assert imported_blueprint.blueprint.inputs == snapshot assert imported_blueprint.suggested_filename == "balloob/motion_light" assert imported_blueprint.blueprint.metadata["source_url"] == url From bdb9994b7e43d3c55e766082019e73067b77b640 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 27 Feb 2023 16:17:57 +0100 Subject: [PATCH 0084/1058] Correct Plugwise gas_consumed_interval sensor (#87449) Co-authored-by: Franck Nijhof --- homeassistant/components/plugwise/sensor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index cf83db4f9c09..354656ecd9ea 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, UnitOfVolume, ) from homeassistant.core import HomeAssistant @@ -303,9 +304,9 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="gas_consumed_interval", name="Gas consumed interval", - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - device_class=SensorDeviceClass.GAS, - state_class=SensorStateClass.TOTAL, + icon="mdi:meter-gas", + native_unit_of_measurement=f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.HOURS}", + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="gas_consumed_cumulative", From ff4de8cd06377f964fa22deb50c6b1efbf4aec4c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 16:19:13 +0100 Subject: [PATCH 0085/1058] Add WS API for creating a Thread network (#88830) * Add WS API for creating a Thread network * Add tests --- homeassistant/components/otbr/__init__.py | 12 ++ .../components/otbr/websocket_api.py | 42 ++++++ tests/components/otbr/test_websocket_api.py | 136 ++++++++++++++++++ 3 files changed, 190 insertions(+) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index ebe2ab002577..c20204022835 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -46,11 +46,23 @@ class OTBRData: url: str api: python_otbr_api.OTBR + @_handle_otbr_error + async def set_enabled(self, enabled: bool) -> None: + """Enable or disable the router.""" + return await self.api.set_enabled(enabled) + @_handle_otbr_error async def get_active_dataset_tlvs(self) -> bytes | None: """Get current active operational dataset in TLVS format, or None.""" return await self.api.get_active_dataset_tlvs() + @_handle_otbr_error + async def create_active_dataset( + self, dataset: python_otbr_api.OperationalDataSet + ) -> None: + """Create an active operational dataset.""" + return await self.api.create_active_dataset(dataset) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index a07819793b19..d88581696c4c 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -1,6 +1,8 @@ """Websocket API for OTBR.""" from typing import TYPE_CHECKING +import python_otbr_api + from homeassistant.components.websocket_api import ( ActiveConnection, async_register_command, @@ -20,6 +22,7 @@ if TYPE_CHECKING: def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" async_register_command(hass, websocket_info) + async_register_command(hass, websocket_create_network) @websocket_command( @@ -51,3 +54,42 @@ async def websocket_info( "active_dataset_tlvs": dataset.hex() if dataset else None, }, ) + + +@websocket_command( + { + "type": "otbr/create_network", + } +) +@async_response +async def websocket_create_network( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: + """Create a new Thread network.""" + if DOMAIN not in hass.data: + connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") + return + + data: OTBRData = hass.data[DOMAIN] + + try: + await data.set_enabled(False) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_enabled_failed", str(exc)) + return + + try: + await data.create_active_dataset( + python_otbr_api.OperationalDataSet(network_name="home-assistant") + ) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "create_active_dataset_failed", str(exc)) + return + + try: + await data.set_enabled(True) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_enabled_failed", str(exc)) + return + + connection.send_result(msg["id"]) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index c071e760eb75..de01c6153e23 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -96,3 +96,139 @@ async def test_get_info_fetch_fails( assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_dataset_failed" + + +async def test_create_network( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + + with patch( + "python_otbr_api.OTBR.create_active_dataset" + ) as create_dataset_mock, patch( + "python_otbr_api.OTBR.set_enabled" + ) as set_enabled_mock: + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] is None + + create_dataset_mock.assert_called_once_with( + python_otbr_api.models.OperationalDataSet(network_name="home-assistant") + ) + assert len(set_enabled_mock.mock_calls) == 2 + assert set_enabled_mock.mock_calls[0][1][0] is False + assert set_enabled_mock.mock_calls[1][1][0] is True + + +async def test_create_network_no_entry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + +async def test_get_info_fetch_fails_1( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch( + "python_otbr_api.OTBR.set_enabled", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "set_enabled_failed" + + +async def test_get_info_fetch_fails_2( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch( + "python_otbr_api.OTBR.set_enabled", + ), patch( + "python_otbr_api.OTBR.create_active_dataset", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "create_active_dataset_failed" + + +async def test_get_info_fetch_fails_3( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch( + "python_otbr_api.OTBR.set_enabled", + side_effect=[None, python_otbr_api.OTBRError], + ), patch( + "python_otbr_api.OTBR.create_active_dataset", + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "set_enabled_failed" From 7419a92a1ba0682a1bb7cf9b12c00ce68879e482 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Feb 2023 16:20:01 +0100 Subject: [PATCH 0086/1058] Cleanup YAML import in aladdin_connect (#88694) --- .../components/aladdin_connect/config_flow.py | 9 ---- .../components/aladdin_connect/cover.py | 47 ++----------------- .../aladdin_connect/test_config_flow.py | 29 ------------ .../components/aladdin_connect/test_cover.py | 37 --------------- 4 files changed, 4 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index 89d3b0faf14f..e5170e9b0a29 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -import logging from typing import Any from AIOAladdinConnect import AladdinConnectClient @@ -20,8 +19,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CLIENT_ID, DOMAIN -_LOGGER = logging.getLogger(__name__) - STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -134,12 +131,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_import( - self, import_data: dict[str, Any] | None = None - ) -> FlowResult: - """Import Aladin Connect config from configuration.yaml.""" - return await self.async_step_user(import_data) - class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 8815ccdbb959..5837920560c7 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -2,63 +2,24 @@ from __future__ import annotations from datetime import timedelta -import logging -from typing import Any, Final +from typing import Any from AIOAladdinConnect import AladdinConnectClient -import voluptuous as vol -from homeassistant.components.cover import ( - PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, - CoverDeviceClass, - CoverEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPENING, -) +from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES from .model import DoorDevice -_LOGGER: Final = logging.getLogger(__name__) - -PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) SCAN_INTERVAL = timedelta(seconds=300) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up Aladdin Connect devices yaml depreciated.""" - _LOGGER.warning( - "Configuring Aladdin Connect through yaml is deprecated. Please remove it from" - " your configuration as it has already been imported to a config entry" - ) - await hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index c2fdd5589de0..6f879994fbed 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -131,35 +131,6 @@ async def test_form_already_configured( assert result2["reason"] == "already_configured" -async def test_import_flow_success( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ), patch( - "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Aladdin Connect" - assert result2["data"] == { - CONF_USERNAME: "test-user", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_reauth_flow( hass: HomeAssistant, mock_aladdinconnect_api: MagicMock ) -> None: diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index d42ee9a51221..e63b50607c4f 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -1,16 +1,12 @@ """Test the Aladdin Connect Cover.""" from unittest.mock import AsyncMock, MagicMock, patch -import pytest - from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_PASSWORD, - CONF_USERNAME, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, STATE_CLOSED, @@ -196,36 +192,3 @@ async def test_cover_operation( await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_UNKNOWN - - -async def test_yaml_import( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mock_aladdinconnect_api: MagicMock, -) -> None: - """Test setup YAML import.""" - assert COVER_DOMAIN not in hass.config.components - - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await async_setup_component( - hass, - COVER_DOMAIN, - { - COVER_DOMAIN: { - "platform": DOMAIN, - "username": "test-user", - "password": "test-password", - } - }, - ) - await hass.async_block_till_done() - assert hass.config_entries.async_entries(DOMAIN) - assert "Configuring Aladdin Connect through yaml is deprecated" in caplog.text - - assert hass.config_entries.async_entries(DOMAIN) - config_data = hass.config_entries.async_entries(DOMAIN)[0].data - assert config_data[CONF_USERNAME] == "test-user" - assert config_data[CONF_PASSWORD] == "test-password" From ac70612ec59efa7523ae0d51857d6f66f60f2efa Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Mon, 27 Feb 2023 15:25:27 +0000 Subject: [PATCH 0087/1058] Improve helper integration scaffold (#88713) --- script/scaffold/generate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index b7e4c58d1a13..e31df7ecf0f8 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -151,13 +151,13 @@ def _custom_tasks(template, info: Info) -> None: ) elif template == "config_flow_helper": - info.update_manifest(config_flow=True) + info.update_manifest(config_flow=True, integration_type="helper") info.update_strings( config={ "step": { "user": { "description": "New NEW_NAME Sensor", - "data": {"entity": "Input sensor", "name": "Name"}, + "data": {"entity_id": "Input sensor", "name": "Name"}, }, }, }, @@ -165,7 +165,7 @@ def _custom_tasks(template, info: Info) -> None: "step": { "init": { "data": { - "entity": "[%key:component::NEW_DOMAIN::config::step::user::description%]" + "entity_id": "[%key:component::NEW_DOMAIN::config::step::user::description%]" }, }, }, From e95944bf9fa4b74fc0eaebe2ae2af8839d3580da Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 27 Feb 2023 16:38:18 +0100 Subject: [PATCH 0088/1058] Add filter options to entity and device selectors (#87536) * Add support for multiple device classes * Add support for entity filter selector * Add support for device filter selector * Apply suggestions * Fix wrong property name * Update snapshot --------- Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/selector.py | 69 ++++++++---- .../blueprint/snapshots/test_importer.ambr | 32 ++++-- tests/helpers/test_selector.py | 100 ++++++++++++++++++ 3 files changed, 170 insertions(+), 31 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 0ba5ee363e9a..fe4709a30210 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -79,27 +79,27 @@ class Selector(Generic[_T]): return {"selector": {self.selector_type: self.config}} -SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( +ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration that provided the entity vol.Optional("integration"): str, # Domain the entity belongs to - vol.Optional("domain"): vol.Any(str, [str]), + vol.Optional("domain"): vol.All(cv.ensure_list, [str]), # Device class of the entity - vol.Optional("device_class"): str, + vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), } ) -class SingleEntitySelectorConfig(TypedDict, total=False): +class EntityFilterSelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" integration: str domain: str | list[str] - device_class: str + device_class: str | list[str] -SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( +DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration linked to it with a config entry vol.Optional("integration"): str, @@ -108,18 +108,21 @@ SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( # Model of device vol.Optional("model"): str, # Device has to contain entities matching this selector - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] + ), } ) -class SingleDeviceSelectorConfig(TypedDict, total=False): +class DeviceFilterSelectorConfig(TypedDict, total=False): """Class to represent a single device selector config.""" integration: str manufacturer: str model: str - entity: SingleEntitySelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + filter: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] class ActionSelectorConfig(TypedDict): @@ -176,8 +179,8 @@ class AddonSelector(Selector[AddonSelectorConfig]): class AreaSelectorConfig(TypedDict, total=False): """Class to represent an area selector config.""" - entity: SingleEntitySelectorConfig - device: SingleDeviceSelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] multiple: bool @@ -189,8 +192,14 @@ class AreaSelector(Selector[AreaSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, - vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("device"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), vol.Optional("multiple", default=False): cv.boolean, } ) @@ -399,7 +408,7 @@ class DeviceSelectorConfig(TypedDict, total=False): integration: str manufacturer: str model: str - entity: SingleEntitySelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] multiple: bool @@ -409,8 +418,14 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" - CONFIG_SCHEMA = SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA.extend( - {vol.Optional("multiple", default=False): cv.boolean} + CONFIG_SCHEMA = DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("filter"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + }, ) def __init__(self, config: DeviceSelectorConfig | None = None) -> None: @@ -457,7 +472,7 @@ class DurationSelector(Selector[DurationSelectorConfig]): return cast(dict[str, float], data) -class EntitySelectorConfig(SingleEntitySelectorConfig, total=False): +class EntitySelectorConfig(EntityFilterSelectorConfig, total=False): """Class to represent an entity selector config.""" exclude_entities: list[str] @@ -471,11 +486,15 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" - CONFIG_SCHEMA = SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.extend( { vol.Optional("exclude_entities"): [str], vol.Optional("include_entities"): [str], vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("filter"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), } ) @@ -784,8 +803,8 @@ class SelectSelector(Selector[SelectSelectorConfig]): class TargetSelectorConfig(TypedDict, total=False): """Class to represent a target selector config.""" - entity: SingleEntitySelectorConfig - device: SingleDeviceSelectorConfig + entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] + device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] class StateSelectorConfig(TypedDict, total=False): @@ -832,8 +851,14 @@ class TargetSelector(Selector[TargetSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { - vol.Optional("entity"): SINGLE_ENTITY_SELECTOR_CONFIG_SCHEMA, - vol.Optional("device"): SINGLE_DEVICE_SELECTOR_CONFIG_SCHEMA, + vol.Optional("entity"): vol.All( + cv.ensure_list, + [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], + ), + vol.Optional("device"): vol.All( + cv.ensure_list, + [DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA], + ), } ) diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 1401a8f1741e..6e5648b54d90 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -18,9 +18,13 @@ 'description': 'The light(s) to control', 'selector': dict({ 'target': OrderedDict({ - 'entity': OrderedDict({ - 'domain': 'light', - }), + 'entity': list([ + OrderedDict({ + 'domain': list([ + 'light', + ]), + }), + ]), }), }), }), @@ -111,9 +115,13 @@ 'description': 'The light(s) to control', 'selector': dict({ 'target': OrderedDict({ - 'entity': OrderedDict({ - 'domain': 'light', - }), + 'entity': list([ + OrderedDict({ + 'domain': list([ + 'light', + ]), + }), + ]), }), }), }), @@ -191,8 +199,12 @@ 'name': 'Motion Sensor', 'selector': dict({ 'entity': OrderedDict({ - 'domain': 'binary_sensor', - 'device_class': 'motion', + 'domain': list([ + 'binary_sensor', + ]), + 'device_class': list([ + 'motion', + ]), 'multiple': False, }), }), @@ -201,7 +213,9 @@ 'name': 'Light', 'selector': dict({ 'entity': OrderedDict({ - 'domain': 'light', + 'domain': list([ + 'light', + ]), 'multiple': False, }), }), diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 75e8a8dc5425..a5fa5c7a50d7 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -92,6 +92,17 @@ def _test_selector( (None,), ), ({"entity": {"device_class": "motion"}}, ("abc123",), (None,)), + ({"entity": {"device_class": ["motion", "temperature"]}}, ("abc123",), (None,)), + ( + { + "entity": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + ("abc123",), + (None,), + ), ( { "integration": "zha", @@ -107,6 +118,35 @@ def _test_selector( (["abc123", "def456"],), ("abc123", None, ["abc123", None]), ), + ( + { + "filter": { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + } + }, + ("abc123",), + (None,), + ), + ( + { + "filter": [ + { + "integration": "zha", + "manufacturer": "mock-manuf", + "model": "mock-model", + }, + { + "integration": "matter", + "manufacturer": "other-mock-manuf", + "model": "other-mock-model", + }, + ] + }, + ("abc123",), + (None,), + ), ), ) def test_device_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -126,6 +166,11 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> (None, "dog.abc123"), ), ({"device_class": "motion"}, ("sensor.abc123", FAKE_UUID), (None, "abc123")), + ( + {"device_class": ["motion", "temperature"]}, + ("sensor.abc123", FAKE_UUID), + (None, "abc123"), + ), ( {"integration": "zha", "domain": "light"}, ("light.abc123", FAKE_UUID), @@ -167,6 +212,21 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> ["sensor.abc123", "sensor.ghi789"], ), ), + ( + {"filter": {"domain": "light"}}, + ("light.abc123", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + ("light.abc123", "binary_sensor.abc123", FAKE_UUID), + (None,), + ), ), ) def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -196,11 +256,31 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> ("abc123",), (None,), ), + ( + { + "entity": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + ("abc123",), + (None,), + ), ( {"device": {"integration": "demo", "model": "mock-model"}}, ("abc123",), (None,), ), + ( + { + "device": [ + {"integration": "demo", "model": "mock-model"}, + {"integration": "other-demo", "model": "other-mock-model"}, + ] + }, + ("abc123",), + (None,), + ), ( { "entity": {"domain": "binary_sensor", "device_class": "motion"}, @@ -345,6 +425,16 @@ def test_state_selector_schema(schema, valid_selections, invalid_selections) -> ({"entity": {}}, (), ()), ({"entity": {"domain": "light"}}, (), ()), ({"entity": {"domain": "binary_sensor", "device_class": "motion"}}, (), ()), + ( + { + "entity": [ + {"domain": "light"}, + {"domain": "binary_sensor", "device_class": "motion"}, + ] + }, + (), + (), + ), ( { "entity": { @@ -357,6 +447,16 @@ def test_state_selector_schema(schema, valid_selections, invalid_selections) -> (), ), ({"device": {"integration": "demo", "model": "mock-model"}}, (), ()), + ( + { + "device": [ + {"integration": "demo", "model": "mock-model"}, + {"integration": "other-demo", "model": "other-mock-model"}, + ], + }, + (), + (), + ), ( { "entity": {"domain": "binary_sensor", "device_class": "motion"}, From 00954dfc1f0e0eb3ebd6d58807e2a660705a839a Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Mon, 27 Feb 2023 16:40:08 +0100 Subject: [PATCH 0089/1058] Add gas sensor to BTHome (#88770) * Bump bthome * Add gas sensor --- 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 47f980c78fea..f875074a9ff9 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==2.5.2"] + "requirements": ["bthome-ble==2.7.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 4b3781834895..981639573070 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -119,6 +119,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), + # Gas (m3) + ( + BTHomeSensorDeviceClass.GAS, + Units.VOLUME_CUBIC_METERS, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.GAS}_{Units.VOLUME_CUBIC_METERS}", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL_INCREASING, + ), # Humidity in (percent) (BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.HUMIDITY}_{Units.PERCENTAGE}", diff --git a/requirements_all.txt b/requirements_all.txt index 9bea267c26ff..680c4ed043ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -492,7 +492,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.5.2 +bthome-ble==2.7.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 3e081f63a7c2..7f4285785376 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ brother==2.2.0 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.5.2 +bthome-ble==2.7.0 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 3b5c434c7935..af01db0a6ef7 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -844,6 +844,23 @@ async def test_v1_sensors( }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x4b\x13\x8a\x14", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_gas", + "friendly_name": "Test Device 18B2 Gas", + "unit_of_measurement": "m³", + "state_class": "total_increasing", + "expected_state": "1346.067", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( From fd87748b99733c2a3ff6f8f4fc835f53bcc1f2ef Mon Sep 17 00:00:00 2001 From: StefanIacobLivisi <109964424+StefanIacobLivisi@users.noreply.github.com> Date: Mon, 27 Feb 2023 18:20:10 +0200 Subject: [PATCH 0090/1058] LIVISI climate device improvement (#88844) Code review follow-up --- homeassistant/components/livisi/climate.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index d0bdbe64bf75..58589b62e3c2 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -139,9 +139,6 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Do nothing as LIVISI devices do not support changing the hvac mode.""" - raise HomeAssistantError( - "This feature is not supported with the LIVISI climate devices" - ) async def async_added_to_hass(self) -> None: """Register callbacks.""" From 0e8d28dab09b17f0c461452ee07ec01132af0be4 Mon Sep 17 00:00:00 2001 From: Emory Penney Date: Mon, 27 Feb 2023 08:22:15 -0800 Subject: [PATCH 0091/1058] Add Config Flow to Obihai (#88627) * Obihai: Config Flow Only * Remove reboot service * Update .coveragerc Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * PR Feedback * Use Issue Registry * Add config_flow tests * Another pass with pre-commit * Resolve cyclical import and move sensorClasses to sensor * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Another round of feedback * More PR feedback * Offline testing, already_configured is required * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Cleanup * Update homeassistant/components/obihai/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * PR feedback * Backout today's changes: Fix mypy error * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/obihai/test_config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Don't plan ahead * PR feedback * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Cleanup strings --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 3 +- CODEOWNERS | 1 + homeassistant/components/obihai/__init__.py | 17 ++++ .../components/obihai/config_flow.py | 73 ++++++++++++++++ .../components/obihai/connectivity.py | 67 +++++++++++++++ homeassistant/components/obihai/const.py | 15 ++++ homeassistant/components/obihai/manifest.json | 1 + homeassistant/components/obihai/sensor.py | 85 +++++++++++-------- homeassistant/components/obihai/strings.json | 25 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/obihai/__init__.py | 10 +++ tests/components/obihai/conftest.py | 15 ++++ tests/components/obihai/test_config_flow.py | 73 ++++++++++++++++ 15 files changed, 353 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/obihai/config_flow.py create mode 100644 homeassistant/components/obihai/connectivity.py create mode 100644 homeassistant/components/obihai/const.py create mode 100644 homeassistant/components/obihai/strings.json create mode 100644 tests/components/obihai/__init__.py create mode 100644 tests/components/obihai/conftest.py create mode 100644 tests/components/obihai/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index a06f4fa92d30..8a5b90b5d766 100644 --- a/.coveragerc +++ b/.coveragerc @@ -807,7 +807,8 @@ omit = homeassistant/components/nuki/sensor.py homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/oasa_telematics/sensor.py - homeassistant/components/obihai/* + homeassistant/components/obihai/connectivity.py + homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py homeassistant/components/oem/climate.py homeassistant/components/ohmconnect/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 94360a4f45b8..24036150fe54 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -826,6 +826,7 @@ build.json @home-assistant/supervisor /homeassistant/components/nzbget/ @chriscla /tests/components/nzbget/ @chriscla /homeassistant/components/obihai/ @dshokouhi +/tests/components/obihai/ @dshokouhi /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 8e65423b73bb..810b24dca201 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -1 +1,18 @@ """The Obihai integration.""" + +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 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/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py new file mode 100644 index 000000000000..dd2aa0db06d3 --- /dev/null +++ b/homeassistant/components/obihai/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow to configure the Obihai integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .connectivity import validate_auth +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional( + CONF_USERNAME, + default=DEFAULT_USERNAME, + ): str, + vol.Optional( + CONF_PASSWORD, + default=DEFAULT_PASSWORD, + ): str, + } +) + + +class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Obihai.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + if await self.hass.async_add_executor_job( + validate_auth, + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ): + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + errors["base"] = "cannot_connect" + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=data_schema, + ) + + # DEPRECATED + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle a flow initialized by importing a config.""" + self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) + return self.async_create_entry( + title=config.get(CONF_NAME, config[CONF_HOST]), + data={ + CONF_HOST: config[CONF_HOST], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_USERNAME: config[CONF_USERNAME], + }, + ) diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py new file mode 100644 index 000000000000..4a5c25b21018 --- /dev/null +++ b/homeassistant/components/obihai/connectivity.py @@ -0,0 +1,67 @@ +"""Support for Obihai Connectivity.""" +from __future__ import annotations + +from pyobihai import PyObihai + +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, LOGGER + + +def get_pyobihai( + host: str, + username: str, + password: str, +) -> PyObihai: + """Retrieve an authenticated PyObihai.""" + return PyObihai(host, username, password) + + +def validate_auth( + host: str, + username: str, + password: str, +) -> bool: + """Test if the given setting works as expected.""" + obi = get_pyobihai(host, username, password) + + login = obi.check_account() + if not login: + LOGGER.debug("Invalid credentials") + return False + + return True + + +class ObihaiConnection: + """Contains a list of Obihai Sensors.""" + + def __init__( + self, + host: str, + username: str = DEFAULT_USERNAME, + password: str = DEFAULT_PASSWORD, + ) -> None: + """Store configuration.""" + self.sensors: list = [] + self.host = host + self.username = username + self.password = password + self.serial: list = [] + self.services: list = [] + self.line_services: list = [] + self.call_direction: list = [] + self.pyobihai: PyObihai = None + + def update(self) -> bool: + """Validate connection and retrieve a list of sensors.""" + if not self.pyobihai: + self.pyobihai = get_pyobihai(self.host, self.username, self.password) + + if not self.pyobihai.check_account(): + return False + + self.serial = self.pyobihai.get_device_serial() + self.services = self.pyobihai.get_state() + self.line_services = self.pyobihai.get_line_state() + self.call_direction = self.pyobihai.get_call_direction() + + return True diff --git a/homeassistant/components/obihai/const.py b/homeassistant/components/obihai/const.py new file mode 100644 index 000000000000..90bcd7736f83 --- /dev/null +++ b/homeassistant/components/obihai/const.py @@ -0,0 +1,15 @@ +"""Constants for the Obihai integration.""" + +import logging +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "obihai" +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin" +OBIHAI = "Obihai" + +LOGGER = logging.getLogger(__package__) + +PLATFORMS: Final = [Platform.SENSOR] diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index 867d7d875dcc..d5bb07805d7d 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -2,6 +2,7 @@ "domain": "obihai", "name": "Obihai", "codeowners": ["@dshokouhi"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/obihai", "iot_class": "local_polling", "loggers": ["pyobihai"], diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index cff4e6232e74..953193a5ab66 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -2,9 +2,7 @@ from __future__ import annotations from datetime import timedelta -import logging -from pyobihai import PyObihai import voluptuous as vol from homeassistant.components.sensor import ( @@ -12,20 +10,19 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .connectivity import ObihaiConnection +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI SCAN_INTERVAL = timedelta(seconds=5) -OBIHAI = "Obihai" -DEFAULT_USERNAME = "admin" -DEFAULT_PASSWORD = "admin" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -35,46 +32,58 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +# DEPRECATED +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Obihai sensor platform.""" + issue_registry.async_create_issue( + hass, + DOMAIN, + "manual_migration", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=issue_registry.IssueSeverity.ERROR, + translation_key="manual_migration", + ) - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - host = config[CONF_HOST] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Obihai sensor entries.""" + + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + host = entry.data[CONF_HOST] + requester = ObihaiConnection(host, username, password) + + await hass.async_add_executor_job(requester.update) sensors = [] + for key in requester.services: + sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) - pyobihai = PyObihai(host, username, password) + if requester.line_services is not None: + for key in requester.line_services: + sensors.append( + ObihaiServiceSensors(requester.pyobihai, requester.serial, key) + ) - login = pyobihai.check_account() - if not login: - _LOGGER.error("Invalid credentials") - return + for key in requester.call_direction: + sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) - serial = pyobihai.get_device_serial() - - services = pyobihai.get_state() - - line_services = pyobihai.get_line_state() - - call_direction = pyobihai.get_call_direction() - - for key in services: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - if line_services is not None: - for key in line_services: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - for key in call_direction: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - add_entities(sensors) + async_add_entities(sensors, update_before_add=True) class ObihaiServiceSensors(SensorEntity): @@ -148,6 +157,10 @@ class ObihaiServiceSensors(SensorEntity): def update(self) -> None: """Update the sensor.""" + if not self._pyobihai.check_account(): + self._state = None + return + services = self._pyobihai.get_state() if self._service_name in services: diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json new file mode 100644 index 000000000000..053343b4501a --- /dev/null +++ b/homeassistant/components/obihai/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "manual_migration": { + "title": "Manual migration required for Obihai", + "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 28ceb593845b..505554767693 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -293,6 +293,7 @@ FLOWS = { "nut", "nws", "nzbget", + "obihai", "octoprint", "omnilogic", "oncue", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cee5b2167a86..0f5e0ff08e26 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3777,7 +3777,7 @@ "obihai": { "name": "Obihai", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "octoprint": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f4285785376..58a40dbd5a14 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1322,6 +1322,9 @@ pynx584==0.5 # homeassistant.components.nzbget pynzbgetapi==0.2.0 +# homeassistant.components.obihai +pyobihai==1.3.2 + # homeassistant.components.octoprint pyoctoprintapi==0.1.11 diff --git a/tests/components/obihai/__init__.py b/tests/components/obihai/__init__.py new file mode 100644 index 000000000000..36d0f58fe4f1 --- /dev/null +++ b/tests/components/obihai/__init__.py @@ -0,0 +1,10 @@ +"""Tests for the Obihai Integration.""" + + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +USER_INPUT = { + CONF_HOST: "10.10.10.30", + CONF_PASSWORD: "admin", + CONF_USERNAME: "admin", +} diff --git a/tests/components/obihai/conftest.py b/tests/components/obihai/conftest.py new file mode 100644 index 000000000000..64e4d4b1a309 --- /dev/null +++ b/tests/components/obihai/conftest.py @@ -0,0 +1,15 @@ +"""Define test fixtures for Obihai.""" + +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.obihai.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py new file mode 100644 index 000000000000..07d00f15775c --- /dev/null +++ b/tests/components/obihai/test_config_flow.py @@ -0,0 +1,73 @@ +"""Test the Obihai config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.obihai.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import USER_INPUT + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the user initiated form.""" + + 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"] == {} + + with patch("pyobihai.PyObihai.check_account"): + 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"] == "10.10.10.30" + assert result["data"] == {**USER_INPUT} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test we get the authentication error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.obihai.config_flow.validate_auth", return_value=False + ): + 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["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_yaml_import(hass: HomeAssistant) -> None: + """Test we get the YAML imported.""" + with patch( + "homeassistant.components.obihai.config_flow.validate_auth", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "errors" not in result From 7cc8712a0c4067f63389963bfbbdb66da36deac7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 27 Feb 2023 17:24:02 +0100 Subject: [PATCH 0092/1058] Change string to enum in SomfyThermostat (#88813) --- .../overkiz/climate_entities/somfy_thermostat.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index 8242fdc85768..aaae64e0454c 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -22,13 +22,10 @@ from ..entity import OverkizEntity PRESET_FREEZE = "freeze" PRESET_NIGHT = "night" -STATE_DEROGATION_ACTIVE = "active" -STATE_DEROGATION_INACTIVE = "inactive" - OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { - STATE_DEROGATION_ACTIVE: HVACMode.HEAT, - STATE_DEROGATION_INACTIVE: HVACMode.AUTO, + OverkizCommandParam.ACTIVE: HVACMode.HEAT, + OverkizCommandParam.INACTIVE: HVACMode.AUTO, } HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} From 79f96fe900e0e8073a71d3f0e3d709d50185b89b Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 27 Feb 2023 17:25:02 +0100 Subject: [PATCH 0093/1058] Support ValveHeatingTemperatureInterface in Overkiz integration (#88804) * Add ValveHeatingTemperatureInterface support * Update presets * Bugfix * Bugfixes * Bugfix * Update manifest * Apply feedback * Sort alphabetically * Update homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../overkiz/climate_entities/__init__.py | 2 + .../valve_heating_temperature_interface.py | 137 ++++++++++++++++++ homeassistant/components/overkiz/const.py | 1 + 3 files changed, 140 insertions(+) create mode 100644 homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index e70315e099d0..9d54c04422a3 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -10,6 +10,7 @@ from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .somfy_thermostat import SomfyThermostat +from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, @@ -21,4 +22,5 @@ WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, + UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, } diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py new file mode 100644 index 000000000000..fdaf0d61f1f7 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -0,0 +1,137 @@ +"""Support for ValveHeatingTemperatureInterface.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, + UnitOfTemperature, +) +from homeassistant.const import ATTR_TEMPERATURE + +from ..const import DOMAIN +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_MANUAL = "manual" +PRESET_FROST_PROTECTION = "frost_protection" + +OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = { + OverkizCommandParam.OPEN: HVACAction.HEATING, + OverkizCommandParam.CLOSED: HVACAction.IDLE, +} + +OVERKIZ_TO_PRESET_MODE: dict[str, str] = { + OverkizCommandParam.GEOFENCING_MODE: PRESET_NONE, + OverkizCommandParam.SUDDEN_DROP_MODE: PRESET_NONE, + OverkizCommandParam.AWAY: PRESET_AWAY, + OverkizCommandParam.COMFORT: PRESET_COMFORT, + OverkizCommandParam.ECO: PRESET_ECO, + OverkizCommandParam.FROSTPROTECTION: PRESET_FROST_PROTECTION, + OverkizCommandParam.MANUAL: PRESET_MANUAL, +} +PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} + +TEMPERATURE_SENSOR_DEVICE_INDEX = 2 + + +class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): + """Representation of Valve Heating Temperature Interface device.""" + + _attr_hvac_mode = HVACMode.HEAT + _attr_hvac_modes = [HVACMode.HEAT] + _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = DOMAIN + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + self.temperature_device = self.executor.linked_device( + TEMPERATURE_SENSOR_DEVICE_INDEX + ) + + self._attr_min_temp = cast( + float, self.executor.select_state(OverkizState.CORE_MIN_SETPOINT) + ) + self._attr_max_temp = cast( + float, self.executor.select_state(OverkizState.CORE_MAX_SETPOINT) + ) + + @property + def hvac_action(self) -> str: + """Return the current running hvac operation.""" + return OVERKIZ_TO_HVAC_ACTION[ + cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE)) + ] + + @property + def target_temperature(self) -> float: + """Return the temperature.""" + return cast( + float, self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) + ) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]: + return temperature.value_as_float + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + float(temperature), + OverkizCommandParam.FURTHER_NOTICE, + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + return + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp.""" + return OVERKIZ_TO_PRESET_MODE[ + cast( + str, self.executor.select_state(OverkizState.IO_DEROGATION_HEATING_MODE) + ) + ] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + + # If we want to switch to manual mode via a preset, we need to pass in a temperature + # Manual mode will be on automatically if an user sets a temperature + if preset_mode == PRESET_MANUAL: + if current_temperature := self.current_temperature: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + current_temperature, + OverkizCommandParam.FURTHER_NOTICE, + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + PRESET_MODE_TO_OVERKIZ[preset_mode], + OverkizCommandParam.FURTHER_NOTICE, + ) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index d176a137544d..806ba435c206 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -83,6 +83,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported) UIWidget.TSKALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) + UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) } # Map Overkiz camelCase to Home Assistant snake_case for translation From 73c7ee4326ba51a8ba92bd951993d6ab20f03402 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 27 Feb 2023 18:45:29 +0100 Subject: [PATCH 0094/1058] Bump odp-amsterdam to v5.1.0 (#88847) --- homeassistant/components/garages_amsterdam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 9dd043a715a7..e2f068b961ca 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==5.0.1"] + "requirements": ["odp-amsterdam==5.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 680c4ed043ee..91b1fb85f0a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1248,7 +1248,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.0.1 +odp-amsterdam==5.1.0 # homeassistant.components.oem oemthermostat==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58a40dbd5a14..243c488d3f9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -923,7 +923,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.0.1 +odp-amsterdam==5.1.0 # homeassistant.components.omnilogic omnilogic==0.4.5 From 7a5a8826875a973b7b8d0f5dcf90f6aa2eab4125 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 27 Feb 2023 12:07:57 -0800 Subject: [PATCH 0095/1058] Bump ZHA dependencies (#88799) * Bump ZHA dependencies * Use `importlib.metadata.version` to get package versions --- homeassistant/components/zha/diagnostics.py | 22 ++++++++------------- homeassistant/components/zha/manifest.json | 6 +++--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 8b025f6eec87..2e0653b47e19 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -2,18 +2,12 @@ from __future__ import annotations import dataclasses +from importlib.metadata import version from typing import Any -import bellows -import pkg_resources -import zigpy from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES from zigpy.zcl import Cluster -import zigpy_deconz -import zigpy_xbee -import zigpy_zigate -import zigpy_znp from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry @@ -79,13 +73,13 @@ async def async_get_config_entry_diagnostics( "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), "versions": { - "bellows": bellows.__version__, - "zigpy": zigpy.__version__, - "zigpy_deconz": zigpy_deconz.__version__, - "zigpy_xbee": zigpy_xbee.__version__, - "zigpy_znp": zigpy_znp.__version__, - "zigpy_zigate": zigpy_zigate.__version__, - "zhaquirks": pkg_resources.get_distribution("zha-quirks").version, + "bellows": version("bellows"), + "zigpy": version("zigpy"), + "zigpy_deconz": version("zigpy-deconz"), + "zigpy_xbee": version("zigpy-xbee"), + "zigpy_znp": version("zigpy_znp"), + "zigpy_zigate": version("zigpy-zigate"), + "zhaquirks": version("zha-quirks"), }, }, KEYS_TO_REDACT, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 1e0d8999d300..44f88aa7339b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.7", + "bellows==0.34.9", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.93", "zigpy-deconz==0.19.2", - "zigpy==0.53.0", + "zigpy==0.53.2", "zigpy-xbee==0.16.2", "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.2" + "zigpy-znp==0.9.3" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 91b1fb85f0a3..61824576d5ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.7 +bellows==0.34.9 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -2724,10 +2724,10 @@ zigpy-xbee==0.16.2 zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.2 +zigpy-znp==0.9.3 # homeassistant.components.zha -zigpy==0.53.0 +zigpy==0.53.2 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 243c488d3f9b..379e0fd6c93f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -352,7 +352,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.7 +bellows==0.34.9 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -1937,10 +1937,10 @@ zigpy-xbee==0.16.2 zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.2 +zigpy-znp==0.9.3 # homeassistant.components.zha -zigpy==0.53.0 +zigpy==0.53.2 # homeassistant.components.zwave_js zwave-js-server-python==0.46.0 From 9fed4472f1d38cea66db36ebd6b6e388b1ecc66c Mon Sep 17 00:00:00 2001 From: Emory Penney Date: Mon, 27 Feb 2023 13:29:51 -0800 Subject: [PATCH 0096/1058] Adding Obihai codeowner (#88856) Obihai: Adding codeowner --- CODEOWNERS | 4 ++-- homeassistant/components/obihai/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 24036150fe54..eb370cf77c91 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -825,8 +825,8 @@ build.json @home-assistant/supervisor /tests/components/nws/ @MatthewFlamm @kamiyo /homeassistant/components/nzbget/ @chriscla /tests/components/nzbget/ @chriscla -/homeassistant/components/obihai/ @dshokouhi -/tests/components/obihai/ @dshokouhi +/homeassistant/components/obihai/ @dshokouhi @ejpenney +/tests/components/obihai/ @dshokouhi @ejpenney /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index d5bb07805d7d..939c170f989f 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -1,7 +1,7 @@ { "domain": "obihai", "name": "Obihai", - "codeowners": ["@dshokouhi"], + "codeowners": ["@dshokouhi", "@ejpenney"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/obihai", "iot_class": "local_polling", From c096ef3fce8bba1528511cdeec0e78d5775dd5c6 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Feb 2023 00:20:40 +0100 Subject: [PATCH 0097/1058] Update frontend to 20230227.0 (#88857) --- 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 1daffd430762..3d6fedb07068 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==20230224.0"] + "requirements": ["home-assistant-frontend==20230227.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 08cccaf7b5f8..16b1969f61be 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.5 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230224.0 +home-assistant-frontend==20230227.0 home-assistant-intents==2023.2.22 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 61824576d5ea..2104d8cd506f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230224.0 +home-assistant-frontend==20230227.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 379e0fd6c93f..9921b37b8029 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230224.0 +home-assistant-frontend==20230227.0 # homeassistant.components.conversation home-assistant-intents==2023.2.22 From 7b3cab1bfe309f9c89913805aa7d82a7ac752217 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 28 Feb 2023 00:22:22 +0100 Subject: [PATCH 0098/1058] Update xknx to 2.6.0 (#88864) --- 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 9bf2731b3d9d..ce09032e1af2 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["xknx"], "quality_scale": "platinum", - "requirements": ["xknx==2.5.0"] + "requirements": ["xknx==2.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2104d8cd506f..451f9c426a6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2653,7 +2653,7 @@ xboxapi==2.0.1 xiaomi-ble==0.16.4 # homeassistant.components.knx -xknx==2.5.0 +xknx==2.6.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9921b37b8029..c9dfd6387138 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1884,7 +1884,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.16.4 # homeassistant.components.knx -xknx==2.5.0 +xknx==2.6.0 # homeassistant.components.bluesound # homeassistant.components.fritz From 07c25b3dd83503dbb5129475f40dfe0f52a08360 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 28 Feb 2023 07:16:22 +0000 Subject: [PATCH 0099/1058] Prosegur late review comments (#88859) * address late comments on #76428 * adress review * extra tweaks --- homeassistant/components/prosegur/camera.py | 4 ++-- tests/components/prosegur/conftest.py | 9 ++++++--- tests/components/prosegur/test_camera.py | 13 ++++++------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 40f8e18fb66c..848b763903a2 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -73,8 +73,8 @@ class ProsegurCamera(Camera): ) -> bytes | None: """Return bytes of camera image.""" + _LOGGER.debug("Get image for %s", self._camera.description) try: - _LOGGER.debug("Get image for %s", self._camera.description) return await self._installation.get_image(self._auth, self._camera.id) except ProsegurException as err: @@ -85,8 +85,8 @@ class ProsegurCamera(Camera): async def async_request_image(self): """Request new image from the camera.""" + _LOGGER.debug("Request image for %s", self._camera.description) try: - _LOGGER.debug("Request image for %s", self._camera.description) await self._installation.request_image(self._auth, self._camera.id) except ProsegurException as err: diff --git a/tests/components/prosegur/conftest.py b/tests/components/prosegur/conftest.py index ea906fdcbff4..bd2ce231e28f 100644 --- a/tests/components/prosegur/conftest.py +++ b/tests/components/prosegur/conftest.py @@ -1,5 +1,5 @@ """Define test fixtures for Prosegur.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from pyprosegur.installation import Camera import pytest @@ -30,9 +30,12 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture def mock_install() -> AsyncMock: """Return the mocked alarm install.""" - install = AsyncMock() + install = MagicMock() install.contract = CONTRACT install.cameras = [Camera("1", "test_cam")] + install.arm = AsyncMock() + install.disarm = AsyncMock() + install.arm_partially = AsyncMock() install.get_image = AsyncMock(return_value=b"ABC") install.request_image = AsyncMock() @@ -51,7 +54,7 @@ async def init_integration( with patch( "pyprosegur.installation.Installation.retrieve", return_value=mock_install - ), patch("pyprosegur.auth.Auth.login", return_value=AsyncMock()): + ), patch("pyprosegur.auth.Auth.login"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/prosegur/test_camera.py b/tests/components/prosegur/test_camera.py index 75e4cbbc7738..40ab57e088b9 100644 --- a/tests/components/prosegur/test_camera.py +++ b/tests/components/prosegur/test_camera.py @@ -27,13 +27,12 @@ async def test_camera_fail(hass, init_integration, mock_install, caplog): return_value=b"ABC", side_effect=ProsegurException() ) - with caplog.at_level(logging.ERROR, logger="homeassistant.components.prosegur"): - try: - await camera.async_get_image(hass, "camera.test_cam") - except HomeAssistantError as exc: - assert str(exc) == "Unable to get image" - else: - assert pytest.fail() + with caplog.at_level( + logging.ERROR, logger="homeassistant.components.prosegur" + ), pytest.raises(HomeAssistantError) as exc: + await camera.async_get_image(hass, "camera.test_cam") + + assert "Unable to get image" in str(exc.value) assert "Image test_cam doesn't exist" in caplog.text From d2ea773e7f580c6019fef701c55dc91a4e3bd463 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Feb 2023 08:54:05 +0100 Subject: [PATCH 0100/1058] Adjust AddEntitiesCallback import (part 1) (#88870) Adjust AddEntitiesCallback import --- homeassistant/components/deluge/sensor.py | 6 ++---- homeassistant/components/deluge/switch.py | 6 ++---- homeassistant/components/efergy/sensor.py | 6 ++---- homeassistant/components/modem_callerid/button.py | 6 ++---- homeassistant/components/modem_callerid/sensor.py | 6 ++---- homeassistant/components/switchbot/humidifier.py | 6 ++---- homeassistant/components/switchbot/switch.py | 6 ++---- 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 12b7ce0dd8d0..eed194640dd7 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, Platform, UnitOfDataRate from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import DelugeEntity @@ -71,9 +71,7 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Deluge sensor.""" async_add_entities( diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index 5b3989384cd6..f9e89543d26b 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DelugeEntity from .const import DOMAIN @@ -15,9 +15,7 @@ from .coordinator import DelugeDataUpdateCoordinator async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Deluge switch.""" async_add_entities([DelugeSwitch(hass.data[DOMAIN][entry.entry_id])]) diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 0fb58319b48e..1f544a7a97b1 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import EfergyEntity @@ -104,9 +104,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Efergy sensors.""" api: Efergy = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 63a88a8a4e5f..4b149deece31 100644 --- a/homeassistant/components/modem_callerid/button.py +++ b/homeassistant/components/modem_callerid/button.py @@ -7,15 +7,13 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_KEY_API, DOMAIN async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index 4f84abd45331..1cb1043a5e00 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -7,15 +7,13 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CID, DATA_KEY_API, DOMAIN, ICON async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Modem Caller ID sensor.""" api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py index 2bb71bacea10..148e4c3545f5 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -14,7 +14,7 @@ from homeassistant.components.humidifier import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import SwitchbotDataUpdateCoordinator @@ -25,9 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Switchbot based on a config entry.""" coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 67749ea0c5aa..76214a4412fe 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .const import DOMAIN @@ -22,9 +22,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Switchbot based on a config entry.""" coordinator: SwitchbotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] From d397217b5b04546c3d47ae7b0812810a3b8cc9bc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 10:23:36 +0100 Subject: [PATCH 0101/1058] Add confirm step to thread zeroconf flow (#88869) Co-authored-by: Martin Hjelmare --- .../components/thread/config_flow.py | 14 ++++++-- homeassistant/components/thread/strings.json | 9 +++++ tests/components/thread/test_config_flow.py | 34 +++++++++++++++---- 3 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/thread/strings.json diff --git a/homeassistant/components/thread/config_flow.py b/homeassistant/components/thread/config_flow.py index 070378b34292..b294dfa51e71 100644 --- a/homeassistant/components/thread/config_flow.py +++ b/homeassistant/components/thread/config_flow.py @@ -1,7 +1,9 @@ """Config flow for the Thread integration.""" from __future__ import annotations -from homeassistant.components import zeroconf +from typing import Any + +from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.data_entry_flow import FlowResult @@ -32,4 +34,12 @@ class ThreadConfigFlow(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Set up because the user has border routers.""" await self._async_handle_discovery_without_unique_id() - return self.async_create_entry(title="Thread", data={}) + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm the setup.""" + if user_input is not None or not onboarding.async_is_onboarded(self.hass): + return self.async_create_entry(title="Thread", data={}) + return self.async_show_form(step_id="confirm") diff --git a/homeassistant/components/thread/strings.json b/homeassistant/components/thread/strings.json new file mode 100644 index 000000000000..0a9cf0004bc9 --- /dev/null +++ b/homeassistant/components/thread/strings.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + } + } +} diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index a514760212b2..7ff096795ca8 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -103,14 +103,18 @@ async def test_user(hass: HomeAssistant) -> None: async def test_zeroconf(hass: HomeAssistant) -> None: """Test the zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "confirm" + with patch( "homeassistant.components.thread.async_setup_entry", return_value=True, ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD - ) - + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Thread" assert result["data"] == {} @@ -124,16 +128,34 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert config_entry.unique_id is None -async def test_zeroconf_then_import(hass: HomeAssistant) -> None: - """Test the import flow.""" +async def test_zeroconf_setup_onboarding(hass: HomeAssistant) -> None: + """Test we automatically finish a zeroconf flow during onboarding.""" with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ), patch( "homeassistant.components.thread.async_setup_entry", return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Thread" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + +async def test_zeroconf_then_import(hass: HomeAssistant) -> None: + """Test the import flow.""" + result = await hass.config_entries.flow.async_init( + thread.DOMAIN, context={"source": "zeroconf"}, data=TEST_ZEROCONF_RECORD + ) + with patch( + "homeassistant.components.thread.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == FlowResultType.CREATE_ENTRY with patch( From bef5fde832df91fa6ccb02f61d13c99944b42be0 Mon Sep 17 00:00:00 2001 From: rodriguestiago0 Date: Tue, 28 Feb 2023 09:28:44 +0000 Subject: [PATCH 0102/1058] Add stop charge button to renault integration (#88003) * Added service to start/stop charge * Remove comment * Fixed service * removed service for start/stop charge * Remove version Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Format Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Revert change * Fix lint * Add tests --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/renault/button.py | 7 +++++ .../components/renault/renault_vehicle.py | 5 ++++ tests/components/renault/const.py | 18 ++++++++++++ .../fixtures/action.set_charge_stop.json | 7 +++++ tests/components/renault/test_button.py | 28 +++++++++++++++++++ 5 files changed, 65 insertions(+) create mode 100644 tests/components/renault/fixtures/action.set_charge_stop.json diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index b34e14d365a4..67dfe8fc971e 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -71,4 +71,11 @@ BUTTON_TYPES: tuple[RenaultButtonEntityDescription, ...] = ( name="Start charge", requires_electricity=True, ), + RenaultButtonEntityDescription( + async_press=lambda x: x.vehicle.set_charge_stop(), + key="stop_charge", + icon="mdi:ev-station", + name="Stop charge", + requires_electricity=True, + ), ) diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 69835552ba4c..9580ea2b7d00 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -151,6 +151,11 @@ class RenaultVehicleProxy: """Start vehicle charge.""" return await self._vehicle.set_charge_start() + @with_error_wrapping + async def set_charge_stop(self) -> models.KamereonVehicleChargingStartActionData: + """Stop vehicle charge.""" + return await self._vehicle.set_charge_stop() + @with_error_wrapping async def set_ac_stop(self) -> models.KamereonVehicleHvacStartActionData: """Stop vehicle ac.""" diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index ee4f1683aad3..e17e6b649481 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -114,6 +114,12 @@ MOCK_VEHICLES = { ATTR_STATE: STATE_UNKNOWN, ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", }, + { + ATTR_ENTITY_ID: "button.reg_number_stop_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_stop_charge", + }, ], Platform.DEVICE_TRACKER: [], Platform.SELECT: [ @@ -336,6 +342,12 @@ MOCK_VEHICLES = { ATTR_STATE: STATE_UNKNOWN, ATTR_UNIQUE_ID: "vf1aaaaa555777999_start_charge", }, + { + ATTR_ENTITY_ID: "button.reg_number_stop_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777999_stop_charge", + }, ], Platform.DEVICE_TRACKER: [ { @@ -565,6 +577,12 @@ MOCK_VEHICLES = { ATTR_STATE: STATE_UNKNOWN, ATTR_UNIQUE_ID: "vf1aaaaa555777123_start_charge", }, + { + ATTR_ENTITY_ID: "button.reg_number_stop_charge", + ATTR_ICON: "mdi:ev-station", + ATTR_STATE: STATE_UNKNOWN, + ATTR_UNIQUE_ID: "vf1aaaaa555777123_stop_charge", + }, ], Platform.DEVICE_TRACKER: [ { diff --git a/tests/components/renault/fixtures/action.set_charge_stop.json b/tests/components/renault/fixtures/action.set_charge_stop.json new file mode 100644 index 000000000000..017059782b62 --- /dev/null +++ b/tests/components/renault/fixtures/action.set_charge_stop.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "ChargingStart", + "id": "guid", + "attributes": { "action": "stop" } + } +} diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py index 8ec18e3b101e..695be73089ef 100644 --- a/tests/components/renault/test_button.py +++ b/tests/components/renault/test_button.py @@ -160,6 +160,34 @@ async def test_button_start_charge( assert mock_action.mock_calls[0][1] == () +@pytest.mark.usefixtures("fixtures_with_data") +@pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) +async def test_button_stop_charge( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Test that button invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + data = { + ATTR_ENTITY_ID: "button.reg_number_stop_charge", + } + + with patch( + "renault_api.renault_vehicle.RenaultVehicle.set_charge_stop", + return_value=( + schemas.KamereonVehicleChargingStartActionDataSchema.loads( + load_fixture("renault/action.set_charge_stop.json") + ) + ), + ) as mock_action: + await hass.services.async_call( + BUTTON_DOMAIN, SERVICE_PRESS, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + assert mock_action.mock_calls[0][1] == () + + @pytest.mark.usefixtures("fixtures_with_data") @pytest.mark.parametrize("vehicle_type", ["zoe_40"], indirect=True) async def test_button_start_air_conditioner( From 4e66554298b5f65e47536535136d2d294efbb83c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Feb 2023 11:14:04 +0100 Subject: [PATCH 0103/1058] Also set `hass.config_entries` when `mock_hass_config` fixture is used (#88669) * Set `hass.config_entries` with `mock_hass_config` * Update tests/conftest.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- tests/conftest.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 22701ee0e81f..61d5a70d9975 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,7 +45,7 @@ from homeassistant.components.websocket_api.auth import ( ) from homeassistant.components.websocket_api.http import URL from homeassistant.config import YAML_CONFIG_FILE -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntries, ConfigEntry from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import ( @@ -938,8 +938,11 @@ def mock_hass_config( ) -> Generator[None, None, None]: """Fixture to mock the content of main configuration. - Patches homeassistant.config.load_yaml_config_file with `hass_config` parameterized as content. + Patches homeassistant.config.load_yaml_config_file and hass.config_entries + with `hass_config` as parameterized. """ + if hass_config: + hass.config_entries = ConfigEntries(hass, hass_config) with patch("homeassistant.config.load_yaml_config_file", return_value=hass_config): yield From b6f66b3568664d17e128a9d911c689b4c4e182d1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 11:35:47 +0100 Subject: [PATCH 0104/1058] Add WS command weather/convertible_units (#85681) --- homeassistant/components/weather/__init__.py | 85 +++++-------------- homeassistant/components/weather/const.py | 76 +++++++++++++++++ .../components/weather/websocket_api.py | 30 +++++++ .../components/weather/test_websocket_api.py | 31 +++++++ 4 files changed, 157 insertions(+), 65 deletions(-) create mode 100644 homeassistant/components/weather/const.py create mode 100644 homeassistant/components/weather/websocket_api.py create mode 100644 tests/components/weather/test_websocket_api.py diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 52642c4f1bf9..0a99b6aaaf7e 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,7 +1,6 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations -from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta @@ -16,8 +15,6 @@ from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, - UnitOfLength, - UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, @@ -30,14 +27,27 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.unit_conversion import ( - DistanceConverter, - PressureConverter, - SpeedConverter, - TemperatureConverter, -) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM +from .const import ( + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, + DOMAIN, + UNIT_CONVERSIONS, + VALID_UNITS, +) +from .websocket_api import async_setup as async_setup_ws_api + _LOGGER = logging.getLogger(__name__) ATTR_CONDITION_CLASS = "condition_class" @@ -71,20 +81,6 @@ ATTR_FORECAST_TIME: Final = "datetime" ATTR_FORECAST_WIND_BEARING: Final = "wind_bearing" ATTR_FORECAST_NATIVE_WIND_SPEED: Final = "native_wind_speed" ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" -ATTR_WEATHER_HUMIDITY = "humidity" -ATTR_WEATHER_OZONE = "ozone" -ATTR_WEATHER_PRESSURE = "pressure" -ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" -ATTR_WEATHER_TEMPERATURE = "temperature" -ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" -ATTR_WEATHER_VISIBILITY = "visibility" -ATTR_WEATHER_VISIBILITY_UNIT = "visibility_unit" -ATTR_WEATHER_WIND_BEARING = "wind_bearing" -ATTR_WEATHER_WIND_SPEED = "wind_speed" -ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" -ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" - -DOMAIN = "weather" ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -92,48 +88,6 @@ SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 -VALID_UNITS_PRESSURE: set[str] = { - UnitOfPressure.HPA, - UnitOfPressure.MBAR, - UnitOfPressure.INHG, - UnitOfPressure.MMHG, -} -VALID_UNITS_TEMPERATURE: set[str] = { - UnitOfTemperature.CELSIUS, - UnitOfTemperature.FAHRENHEIT, -} -VALID_UNITS_PRECIPITATION: set[str] = { - UnitOfPrecipitationDepth.MILLIMETERS, - UnitOfPrecipitationDepth.INCHES, -} -VALID_UNITS_VISIBILITY: set[str] = { - UnitOfLength.KILOMETERS, - UnitOfLength.MILES, -} -VALID_UNITS_WIND_SPEED: set[str] = { - UnitOfSpeed.FEET_PER_SECOND, - UnitOfSpeed.KILOMETERS_PER_HOUR, - UnitOfSpeed.KNOTS, - UnitOfSpeed.METERS_PER_SECOND, - UnitOfSpeed.MILES_PER_HOUR, -} - -UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { - ATTR_WEATHER_PRESSURE_UNIT: PressureConverter.convert, - ATTR_WEATHER_TEMPERATURE_UNIT: TemperatureConverter.convert, - ATTR_WEATHER_VISIBILITY_UNIT: DistanceConverter.convert, - ATTR_WEATHER_PRECIPITATION_UNIT: DistanceConverter.convert, - ATTR_WEATHER_WIND_SPEED_UNIT: SpeedConverter.convert, -} - -VALID_UNITS: dict[str, set[str]] = { - ATTR_WEATHER_PRESSURE_UNIT: VALID_UNITS_PRESSURE, - ATTR_WEATHER_TEMPERATURE_UNIT: VALID_UNITS_TEMPERATURE, - ATTR_WEATHER_VISIBILITY_UNIT: VALID_UNITS_VISIBILITY, - ATTR_WEATHER_PRECIPITATION_UNIT: VALID_UNITS_PRECIPITATION, - ATTR_WEATHER_WIND_SPEED_UNIT: VALID_UNITS_WIND_SPEED, -} - # mypy: disallow-any-generics @@ -182,6 +136,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component = hass.data[DOMAIN] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) + async_setup_ws_api(hass) await component.async_setup(config) return True diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py new file mode 100644 index 000000000000..2dcfd8a2ddc1 --- /dev/null +++ b/homeassistant/components/weather/const.py @@ -0,0 +1,76 @@ +"""Constants for weather.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Final + +from homeassistant.const import ( + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.util.unit_conversion import ( + DistanceConverter, + PressureConverter, + SpeedConverter, + TemperatureConverter, +) + +ATTR_WEATHER_HUMIDITY = "humidity" +ATTR_WEATHER_OZONE = "ozone" +ATTR_WEATHER_PRESSURE = "pressure" +ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" +ATTR_WEATHER_TEMPERATURE = "temperature" +ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" +ATTR_WEATHER_VISIBILITY = "visibility" +ATTR_WEATHER_VISIBILITY_UNIT = "visibility_unit" +ATTR_WEATHER_WIND_BEARING = "wind_bearing" +ATTR_WEATHER_WIND_SPEED = "wind_speed" +ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" +ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" + +DOMAIN: Final = "weather" + +VALID_UNITS_PRESSURE: set[str] = { + UnitOfPressure.HPA, + UnitOfPressure.MBAR, + UnitOfPressure.INHG, + UnitOfPressure.MMHG, +} +VALID_UNITS_TEMPERATURE: set[str] = { + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, +} +VALID_UNITS_PRECIPITATION: set[str] = { + UnitOfPrecipitationDepth.MILLIMETERS, + UnitOfPrecipitationDepth.INCHES, +} +VALID_UNITS_VISIBILITY: set[str] = { + UnitOfLength.KILOMETERS, + UnitOfLength.MILES, +} +VALID_UNITS_WIND_SPEED: set[str] = { + UnitOfSpeed.FEET_PER_SECOND, + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.KNOTS, + UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILES_PER_HOUR, +} + +UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { + ATTR_WEATHER_PRESSURE_UNIT: PressureConverter.convert, + ATTR_WEATHER_TEMPERATURE_UNIT: TemperatureConverter.convert, + ATTR_WEATHER_VISIBILITY_UNIT: DistanceConverter.convert, + ATTR_WEATHER_PRECIPITATION_UNIT: DistanceConverter.convert, + ATTR_WEATHER_WIND_SPEED_UNIT: SpeedConverter.convert, +} + +VALID_UNITS: dict[str, set[str]] = { + ATTR_WEATHER_PRESSURE_UNIT: VALID_UNITS_PRESSURE, + ATTR_WEATHER_TEMPERATURE_UNIT: VALID_UNITS_TEMPERATURE, + ATTR_WEATHER_VISIBILITY_UNIT: VALID_UNITS_VISIBILITY, + ATTR_WEATHER_PRECIPITATION_UNIT: VALID_UNITS_PRECIPITATION, + ATTR_WEATHER_WIND_SPEED_UNIT: VALID_UNITS_WIND_SPEED, +} diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py new file mode 100644 index 000000000000..793efeeed7ea --- /dev/null +++ b/homeassistant/components/weather/websocket_api.py @@ -0,0 +1,30 @@ +"""The weather websocket API.""" +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 .const import VALID_UNITS + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the weather websocket API.""" + websocket_api.async_register_command(hass, ws_convertible_units) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "weather/convertible_units", + } +) +def ws_convertible_units( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Return supported units for a device class.""" + connection.send_result(msg["id"], {"units": VALID_UNITS}) diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py new file mode 100644 index 000000000000..3995bd2a54ba --- /dev/null +++ b/tests/components/weather/test_websocket_api.py @@ -0,0 +1,31 @@ +"""Test the weather websocket API.""" +from pytest_unordered import unordered + +from homeassistant.components.weather.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def test_device_class_units(hass: HomeAssistant, hass_ws_client) -> None: + """Test we can get supported units.""" + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "weather/convertible_units", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "units": { + "precipitation_unit": unordered(["mm", "in"]), + "pressure_unit": unordered(["mbar", "mmHg", "inHg", "hPa"]), + "temperature_unit": unordered(["°F", "°C"]), + "visibility_unit": unordered(["km", "mi"]), + "wind_speed_unit": unordered(["mph", "km/h", "kn", "m/s", "ft/s"]), + } + } From 4d58c9de8d13b110ca3deffe1c37a35d9bcc2809 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Tue, 28 Feb 2023 13:08:52 +0100 Subject: [PATCH 0105/1058] Add human readable name for Livisi climate devices (#88891) * Add human readable climate device name * Remove room name from entity name and set "has_entity_name" --- homeassistant/components/livisi/climate.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 58589b62e3c2..f99ad8dbe72e 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -70,6 +70,7 @@ def create_entity( ) -> ClimateEntity: """Create Climate Entity.""" capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] + config_details: Mapping[str, Any] = device["config"] room_id: str = device["location"] room_name: str = coordinator.rooms[room_id] livisi_climate = LivisiClimate( @@ -82,6 +83,7 @@ def create_entity( temperature_capability=capabilities["RoomTemperature"], humidity_capability=capabilities["RoomHumidity"], room=room_name, + name=config_details["name"], ) return livisi_climate @@ -95,6 +97,7 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_target_temperature_high = MAX_TEMPERATURE _attr_target_temperature_low = MIN_TEMPERATURE + _attr_has_entity_name = True def __init__( self, @@ -107,6 +110,7 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit temperature_capability: str, humidity_capability: str, room: str, + name: str, ) -> None: """Initialize the Livisi Climate.""" self.config_entry = config_entry @@ -116,6 +120,7 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit self._humidity_capability = humidity_capability self.aio_livisi = coordinator.aiolivisi self._attr_available = False + self._attr_name = name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, manufacturer=manufacturer, From f41bec6ba9ae1734db1a3572d9bad18a0c383bb3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 13:50:56 +0100 Subject: [PATCH 0106/1058] Create repairs issue if Thread network is insecure (#88888) * Bump python-otbr-api to 1.0.5 * Create repairs issue if Thread network is insecure * Address review comments --- homeassistant/components/otbr/__init__.py | 65 ++++++++++++++++++- homeassistant/components/otbr/manifest.json | 2 +- homeassistant/components/otbr/strings.json | 6 ++ homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/otbr/__init__.py | 12 ++++ tests/components/otbr/conftest.py | 6 +- tests/components/otbr/test_init.py | 43 +++++++++++- 9 files changed, 131 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index c20204022835..78c5893c889b 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -9,11 +9,14 @@ from typing import Any, Concatenate, ParamSpec, TypeVar import aiohttp import python_otbr_api +from python_otbr_api import tlv_parser +from python_otbr_api.pskc import compute_pskc from homeassistant.components.thread import async_add_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -23,6 +26,18 @@ from .const import DOMAIN _R = TypeVar("_R") _P = ParamSpec("_P") +INSECURE_NETWORK_KEYS = ( + # Thread web UI default + bytes.fromhex("00112233445566778899AABBCCDDEEFF"), +) + +INSECURE_PASSPHRASES = ( + # Thread web UI default + "j01Nme", + # Thread documentation default + "J01NME", +) + def _handle_otbr_error( func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] @@ -70,21 +85,65 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _warn_on_default_network_settings( + hass: HomeAssistant, entry: ConfigEntry, dataset_tlvs: bytes +) -> None: + """Warn user if insecure default network settings are used.""" + dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) + insecure = False + + if ( + network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) + ) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS: + insecure = True + if ( + not insecure + and tlv_parser.MeshcopTLVType.EXTPANID in dataset + and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset + and tlv_parser.MeshcopTLVType.PSKC in dataset + ): + ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] + network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] + pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) + for passphrase in INSECURE_PASSPHRASES: + if pskc == compute_pskc(ext_pan_id, network_name, passphrase): + insecure = True + break + + if insecure: + ir.async_create_issue( + hass, + DOMAIN, + f"insecure_thread_network_{entry.entry_id}", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="insecure_thread_network", + ) + else: + ir.async_delete_issue( + hass, + DOMAIN, + f"insecure_thread_network_{entry.entry_id}", + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Open Thread Border Router config entry.""" api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10) otbrdata = OTBRData(entry.data["url"], api) try: - dataset = await otbrdata.get_active_dataset_tlvs() + dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( HomeAssistantError, aiohttp.ClientError, asyncio.TimeoutError, ) as err: raise ConfigEntryNotReady("Unable to connect") from err - if dataset: - await async_add_dataset(hass, entry.title, dataset.hex()) + if dataset_tlvs: + _warn_on_default_network_settings(hass, entry, dataset_tlvs) + await async_add_dataset(hass, entry.title, dataset_tlvs.hex()) hass.data[DOMAIN] = otbrdata diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 24fb89f21404..0a6482b040ee 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==1.0.4"] + "requirements": ["python-otbr-api==1.0.5"] } diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index 58b32276ba84..a05c3f3e926a 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -14,5 +14,11 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "issues": { + "insecure_thread_network": { + "title": "Insecure Thread network settings detected", + "description": "Your Thread network is using a default network key or pass phrase.\n\nThis is a security risk, please create a new Thread network." + } } } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 16fadd9b06e5..547def834502 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==1.0.4", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==1.0.5", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 451f9c426a6d..37e8ce7c8299 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2097,7 +2097,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.4 +python-otbr-api==1.0.5 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9dfd6387138..d6df57112a3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1493,7 +1493,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.4 +python-otbr-api==1.0.5 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index 2180a091eb75..a133f6fda306 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -6,3 +6,15 @@ DATASET = bytes.fromhex( "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" ) + +DATASET_INSECURE_NW_KEY = bytes.fromhex( + "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDD24657" + "0A336069051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_INSECURE_PASSPHRASE = bytes.fromhex( + "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDD24657" + "0A336069051000112233445566778899AABBCCDDEEFA030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index d02524cb6159..ac120b3e1645 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -20,7 +20,11 @@ async def otbr_config_entry_fixture(hass): title="Open Thread Border Router", ) config_entry.add_to_hass(hass) - with patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET): + with patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET + ), patch( + "homeassistant.components.otbr.compute_pskc" + ): # Patch to speed up tests assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 7818d736e0e8..9261004ec1c4 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -10,8 +10,15 @@ import python_otbr_api from homeassistant.components import otbr from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir -from . import BASE_URL, CONFIG_ENTRY_DATA, DATASET +from . import ( + BASE_URL, + CONFIG_ENTRY_DATA, + DATASET, + DATASET_INSECURE_NW_KEY, + DATASET_INSECURE_PASSPHRASE, +) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -19,6 +26,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_import_dataset(hass: HomeAssistant) -> None: """Test the active dataset is imported at setup.""" + issue_registry = ir.async_get(hass) config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -35,6 +43,39 @@ async def test_import_dataset(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with(config_entry.title, DATASET.hex()) + assert not issue_registry.async_get_issue( + domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" + ) + + +@pytest.mark.parametrize( + "dataset", [DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE] +) +async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> None: + """Test the active dataset is imported at setup. + + This imports a dataset with insecure settings. + """ + issue_registry = ir.async_get(hass) + + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + ) + config_entry.add_to_hass(hass) + with patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset + ), 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(config_entry.title, dataset.hex()) + assert issue_registry.async_get_issue( + domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" + ) @pytest.mark.parametrize( From 1c4aa26ab66c6d37d383bb6fc9b9b7f2d70ff995 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Tue, 28 Feb 2023 14:48:13 +0100 Subject: [PATCH 0107/1058] Add myself to codeowners of Livisi integration (#88900) Add @planbnet to codeowners of Livisi integration --- CODEOWNERS | 4 ++-- homeassistant/components/livisi/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index eb370cf77c91..edee0e9b53bc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -659,8 +659,8 @@ build.json @home-assistant/supervisor /tests/components/litejet/ @joncar /homeassistant/components/litterrobot/ @natekspencer @tkdrob /tests/components/litterrobot/ @natekspencer @tkdrob -/homeassistant/components/livisi/ @StefanIacobLivisi -/tests/components/livisi/ @StefanIacobLivisi +/homeassistant/components/livisi/ @StefanIacobLivisi @planbnet +/tests/components/livisi/ @StefanIacobLivisi @planbnet /homeassistant/components/local_calendar/ @allenporter /tests/components/local_calendar/ @allenporter /homeassistant/components/local_ip/ @issacg diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 6cdebeb307f6..5b5facd44554 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -1,7 +1,7 @@ { "domain": "livisi", "name": "LIVISI Smart Home", - "codeowners": ["@StefanIacobLivisi"], + "codeowners": ["@StefanIacobLivisi", "@planbnet"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", "iot_class": "local_polling", From 7b5c978b95571a2028e963c6bcba30c368a9c6fe Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Feb 2023 14:59:48 +0100 Subject: [PATCH 0108/1058] Add missing mock in overkiz config flow tests (#88899) --- tests/components/overkiz/conftest.py | 14 +++++++++++ tests/components/overkiz/test_config_flow.py | 26 +++++++++----------- 2 files changed, 25 insertions(+), 15 deletions(-) create mode 100644 tests/components/overkiz/conftest.py diff --git a/tests/components/overkiz/conftest.py b/tests/components/overkiz/conftest.py new file mode 100644 index 000000000000..6e00b6f5fe21 --- /dev/null +++ b/tests/components/overkiz/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for overkiz 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.overkiz.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 0156833b8665..89b0b7e84273 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for Overkiz (by Somfy) config flow.""" from __future__ import annotations -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError from pyoverkiz.exceptions import ( @@ -21,6 +21,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + TEST_EMAIL = "test@testdomain.com" TEST_EMAIL2 = "test@testdomain.nl" TEST_PASSWORD = "test-password" @@ -49,7 +51,7 @@ FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( ) -async def test_form(hass: HomeAssistant) -> None: +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} @@ -60,9 +62,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=None - ), patch( - "homeassistant.components.overkiz.async_setup_entry", return_value=True - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, @@ -157,7 +157,7 @@ async def test_abort_on_duplicate_entry(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE, - ), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True): + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD}, @@ -182,7 +182,7 @@ async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE, - ), patch("homeassistant.components.overkiz.async_setup_entry", return_value=True): + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, @@ -197,7 +197,7 @@ async def test_allow_multiple_unique_entries(hass: HomeAssistant) -> None: } -async def test_dhcp_flow(hass: HomeAssistant) -> None: +async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that DHCP discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -214,9 +214,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=None - ), patch( - "homeassistant.components.overkiz.async_setup_entry", return_value=True - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, @@ -256,7 +254,7 @@ async def test_dhcp_flow_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_zeroconf_flow(hass: HomeAssistant) -> None: +async def test_zeroconf_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that zeroconf discovery for new bridge works.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -269,9 +267,7 @@ async def test_zeroconf_flow(hass: HomeAssistant) -> None: with patch("pyoverkiz.client.OverkizClient.login", return_value=True), patch( "pyoverkiz.client.OverkizClient.get_gateways", return_value=None - ), patch( - "homeassistant.components.overkiz.async_setup_entry", return_value=True - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, From e3e4b449585ad781334379fbca0fecdf5ca928b2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 28 Feb 2023 15:02:40 +0100 Subject: [PATCH 0109/1058] Fix string for OTBR config flow abort (#88902) --- homeassistant/components/otbr/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index a05c3f3e926a..f2efea0c1e8f 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -12,7 +12,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "issues": { From a2a23564a47798c0e7a09d8db83aab4be26d2575 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Feb 2023 15:50:00 +0100 Subject: [PATCH 0110/1058] Do not create Area for Hue zones (#88904) Do not create HA area for Hue zones --- homeassistant/components/hue/scene.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index abf3e5412efe..1020879ce816 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -118,13 +118,14 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity): """Return device (service) info.""" # we create a virtual service/device for Hue scenes # so we have a parent for grouped lights and scenes + group_type = self.group.type.value.title() return DeviceInfo( identifiers={(DOMAIN, self.group.id)}, entry_type=DeviceEntryType.SERVICE, name=self.group.metadata.name, manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name, model=self.group.type.value.title(), - suggested_area=self.group.metadata.name, + suggested_area=self.group.metadata.name if group_type == "Room" else None, via_device=(DOMAIN, self.bridge.api.config.bridge_device.id), ) From 390daf1723c395535f87cfae2f6cba5ecc58892a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 16:12:49 +0100 Subject: [PATCH 0111/1058] Sort unit lists sent to frontend (#88898) --- .../components/number/websocket_api.py | 7 +++-- .../components/sensor/websocket_api.py | 7 +++-- .../components/weather/websocket_api.py | 5 +++- tests/components/number/test_websocket_api.py | 15 ++++------ tests/components/sensor/test_websocket_api.py | 28 +++++++++++-------- .../components/weather/test_websocket_api.py | 12 ++++---- 6 files changed, 40 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/number/websocket_api.py b/homeassistant/components/number/websocket_api.py index eca280d7d43f..1ca61fd158f4 100644 --- a/homeassistant/components/number/websocket_api.py +++ b/homeassistant/components/number/websocket_api.py @@ -29,7 +29,10 @@ def ws_device_class_units( ) -> None: """Return supported units for a device class.""" device_class = msg["device_class"] - convertible_units = set() + convertible_units = [] if device_class in UNIT_CONVERTERS and device_class in DEVICE_CLASS_UNITS: - convertible_units = DEVICE_CLASS_UNITS[device_class] + convertible_units = sorted( + DEVICE_CLASS_UNITS[device_class], + key=lambda s: str.casefold(str(s)), + ) connection.send_result(msg["id"], {"units": convertible_units}) diff --git a/homeassistant/components/sensor/websocket_api.py b/homeassistant/components/sensor/websocket_api.py index 10699b8c1c65..2457bfcabe35 100644 --- a/homeassistant/components/sensor/websocket_api.py +++ b/homeassistant/components/sensor/websocket_api.py @@ -29,7 +29,10 @@ def ws_device_class_units( ) -> None: """Return supported units for a device class.""" device_class = msg["device_class"] - convertible_units = set() + convertible_units = [] if device_class in UNIT_CONVERTERS and device_class in DEVICE_CLASS_UNITS: - convertible_units = DEVICE_CLASS_UNITS[device_class] + convertible_units = sorted( + DEVICE_CLASS_UNITS[device_class], + key=lambda s: str.casefold(str(s)), + ) connection.send_result(msg["id"], {"units": convertible_units}) diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py index 793efeeed7ea..51f129fc4a2c 100644 --- a/homeassistant/components/weather/websocket_api.py +++ b/homeassistant/components/weather/websocket_api.py @@ -27,4 +27,7 @@ def ws_convertible_units( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return supported units for a device class.""" - connection.send_result(msg["id"], {"units": VALID_UNITS}) + sorted_units = { + key: sorted(units, key=str.casefold) for key, units in VALID_UNITS.items() + } + connection.send_result(msg["id"], {"units": sorted_units}) diff --git a/tests/components/number/test_websocket_api.py b/tests/components/number/test_websocket_api.py index 4f487a6326a8..194f24dc9daf 100644 --- a/tests/components/number/test_websocket_api.py +++ b/tests/components/number/test_websocket_api.py @@ -1,6 +1,4 @@ """Test the number websocket API.""" -from pytest_unordered import unordered - from homeassistant.components.number.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,21 +15,19 @@ async def test_device_class_units( client = await hass_ws_client(hass) # Device class with units which number allows customizing & converting - await client.send_json( + await client.send_json_auto_id( { - "id": 1, "type": "number/device_class_convertible_units", "device_class": "temperature", } ) msg = await client.receive_json() assert msg["success"] - assert msg["result"] == {"units": unordered(["°F", "°C", "K"])} + assert msg["result"] == {"units": ["K", "°C", "°F"]} # Device class with units which number doesn't allow customizing & converting - await client.send_json( + await client.send_json_auto_id( { - "id": 2, "type": "number/device_class_convertible_units", "device_class": "energy", } @@ -41,13 +37,12 @@ async def test_device_class_units( assert msg["result"] == {"units": []} # Unknown device class - await client.send_json( + await client.send_json_auto_id( { - "id": 3, "type": "number/device_class_convertible_units", "device_class": "kebabsås", } ) msg = await client.receive_json() assert msg["success"] - assert msg["result"] == {"units": unordered([])} + assert msg["result"] == {"units": []} diff --git a/tests/components/sensor/test_websocket_api.py b/tests/components/sensor/test_websocket_api.py index 91eff6e277bb..17b8a2ab5cb3 100644 --- a/tests/components/sensor/test_websocket_api.py +++ b/tests/components/sensor/test_websocket_api.py @@ -1,6 +1,4 @@ """Test the sensor websocket API.""" -from pytest_unordered import unordered - from homeassistant.components.sensor.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,9 +15,8 @@ async def test_device_class_units( client = await hass_ws_client(hass) # Device class with units which sensor allows customizing & converting - await client.send_json( + await client.send_json_auto_id( { - "id": 1, "type": "sensor/device_class_convertible_units", "device_class": "speed", } @@ -27,15 +24,23 @@ async def test_device_class_units( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "units": unordered( - ["km/h", "kn", "mph", "in/h", "in/d", "ft/s", "mm/d", "mm/h", "m/s"] - ) + "units": ["ft/s", "in/d", "in/h", "km/h", "kn", "m/s", "mm/d", "mm/h", "mph"] } + # Device class with units which include `None` + await client.send_json_auto_id( + { + "type": "sensor/device_class_convertible_units", + "device_class": "power_factor", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"units": ["%", None]} + # Device class with units which sensor doesn't allow customizing & converting - await client.send_json( + await client.send_json_auto_id( { - "id": 2, "type": "sensor/device_class_convertible_units", "device_class": "pm1", } @@ -45,13 +50,12 @@ async def test_device_class_units( assert msg["result"] == {"units": []} # Unknown device class - await client.send_json( + await client.send_json_auto_id( { - "id": 3, "type": "sensor/device_class_convertible_units", "device_class": "kebabsås", } ) msg = await client.receive_json() assert msg["success"] - assert msg["result"] == {"units": unordered([])} + assert msg["result"] == {"units": []} diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py index 3995bd2a54ba..1112d7713ed0 100644 --- a/tests/components/weather/test_websocket_api.py +++ b/tests/components/weather/test_websocket_api.py @@ -1,6 +1,4 @@ """Test the weather websocket API.""" -from pytest_unordered import unordered - from homeassistant.components.weather.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -22,10 +20,10 @@ async def test_device_class_units(hass: HomeAssistant, hass_ws_client) -> None: assert msg["success"] assert msg["result"] == { "units": { - "precipitation_unit": unordered(["mm", "in"]), - "pressure_unit": unordered(["mbar", "mmHg", "inHg", "hPa"]), - "temperature_unit": unordered(["°F", "°C"]), - "visibility_unit": unordered(["km", "mi"]), - "wind_speed_unit": unordered(["mph", "km/h", "kn", "m/s", "ft/s"]), + "precipitation_unit": ["in", "mm"], + "pressure_unit": ["hPa", "inHg", "mbar", "mmHg"], + "temperature_unit": ["°C", "°F"], + "visibility_unit": ["km", "mi"], + "wind_speed_unit": ["ft/s", "km/h", "kn", "m/s", "mph"], } } From e74613f8bed330c2ffdab64fa9c9a65a64401478 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Feb 2023 17:04:10 +0100 Subject: [PATCH 0112/1058] Fix removal of non device-bound resources in Hue (#88897) Fix removal of non device-bound resources (like entertainment areas) --- homeassistant/components/hue/v2/entity.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 85b704658549..5878f01889b8 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -55,7 +55,13 @@ class HueBaseEntity(Entity): self._attr_unique_id = resource.id # device is precreated in main handler # this attaches the entity to the precreated device - if self.device is not None: + if self.device is None: + # attach all device-less entities to the bridge itself + # e.g. config based sensors like entertainment area + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, bridge.api.config.bridge.bridge_id)}, + ) + else: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.device.id)}, ) @@ -137,17 +143,14 @@ class HueBaseEntity(Entity): def _handle_event(self, event_type: EventType, resource: HueResource) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_DELETED: - # remove any services created for zones/rooms + # handle removal of room and zone 'virtual' devices/services # regular devices are removed automatically by the logic in device.py. if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE): dev_reg = async_get_device_registry(self.hass) if device := dev_reg.async_get_device({(DOMAIN, resource.id)}): dev_reg.async_remove_device(device.id) - if resource.type in ( - ResourceTypes.GROUPED_LIGHT, - ResourceTypes.SCENE, - ResourceTypes.SMART_SCENE, - ): + # cleanup entities that are not strictly device-bound and have the bridge as parent + if self.device is None: ent_reg = async_get_entity_registry(self.hass) ent_reg.async_remove(self.entity_id) return From f93bd8ef2c6acef44fe54f46e2fe86e70e1a7735 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 17:08:45 +0100 Subject: [PATCH 0113/1058] Only allow channel 15 during configuration of OTBR (#88874) * Only allow channel 15 during automatic configuration of OTBR * Also force channel 15 when creating a new network --- homeassistant/components/otbr/config_flow.py | 24 +++++-- homeassistant/components/otbr/const.py | 2 + .../components/otbr/websocket_api.py | 10 ++- tests/components/otbr/__init__.py | 9 ++- tests/components/otbr/conftest.py | 4 +- tests/components/otbr/test_config_flow.py | 69 +++++++++++++++++-- tests/components/otbr/test_init.py | 6 +- tests/components/otbr/test_websocket_api.py | 4 +- 8 files changed, 111 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 00aae5b8a078..0e9c8e960600 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -6,6 +6,7 @@ import logging import aiohttp import python_otbr_api +from python_otbr_api import tlv_parser import voluptuous as vol from homeassistant.components.hassio import HassioServiceInfo @@ -15,7 +16,7 @@ from homeassistant.const import CONF_URL from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import DEFAULT_CHANNEL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -29,11 +30,26 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): """Connect to the OTBR and create a dataset if it doesn't have one.""" api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10) if await api.get_active_dataset_tlvs() is None: - if dataset := await async_get_preferred_dataset(self.hass): - await api.set_active_dataset_tlvs(bytes.fromhex(dataset)) + # We currently have no way to know which channel zha is using, assume it's + # the default + zha_channel = DEFAULT_CHANNEL + thread_dataset_channel = None + thread_dataset_tlv = await async_get_preferred_dataset(self.hass) + if thread_dataset_tlv: + dataset = tlv_parser.parse_tlv(thread_dataset_tlv) + if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): + thread_dataset_channel = int(channel_str, base=16) + + if thread_dataset_tlv is not None and zha_channel == thread_dataset_channel: + await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv)) else: + _LOGGER.debug( + "not importing TLV with channel %s", thread_dataset_channel + ) await api.create_active_dataset( - python_otbr_api.OperationalDataSet(network_name="home-assistant") + python_otbr_api.OperationalDataSet( + channel=zha_channel, network_name="home-assistant" + ) ) await api.set_enabled(True) diff --git a/homeassistant/components/otbr/const.py b/homeassistant/components/otbr/const.py index 72884a198d81..cc3e4a9e6c3a 100644 --- a/homeassistant/components/otbr/const.py +++ b/homeassistant/components/otbr/const.py @@ -1,3 +1,5 @@ """Constants for the Open Thread Border Router integration.""" DOMAIN = "otbr" + +DEFAULT_CHANNEL = 15 diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index d88581696c4c..7c69a8d0a2d9 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -12,7 +12,7 @@ from homeassistant.components.websocket_api import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .const import DOMAIN +from .const import DEFAULT_CHANNEL, DOMAIN if TYPE_CHECKING: from . import OTBRData @@ -70,6 +70,10 @@ async def websocket_create_network( connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") return + # We currently have no way to know which channel zha is using, assume it's + # the default + zha_channel = DEFAULT_CHANNEL + data: OTBRData = hass.data[DOMAIN] try: @@ -80,7 +84,9 @@ async def websocket_create_network( try: await data.create_active_dataset( - python_otbr_api.OperationalDataSet(network_name="home-assistant") + python_otbr_api.OperationalDataSet( + channel=zha_channel, network_name="home-assistant" + ) ) except HomeAssistantError as exc: connection.send_error(msg["id"], "create_active_dataset_failed", str(exc)) diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index a133f6fda306..d6b2a406aa1b 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -1,7 +1,14 @@ """Tests for the Open Thread Border Router integration.""" BASE_URL = "http://core-silabs-multiprotocol:8081" CONFIG_ENTRY_DATA = {"url": "http://core-silabs-multiprotocol:8081"} -DATASET = bytes.fromhex( + +DATASET_CH15 = bytes.fromhex( + "0E080000000000010000000300000F35060004001FFFE00208F642646DA209B1C00708FDF57B5A" + "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" + "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" +) + +DATASET_CH16 = bytes.fromhex( "0E080000000000010000000300001035060004001FFFE00208F642646DA209B1C00708FDF57B5A" "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index ac120b3e1645..368ecfe80958 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components import otbr -from . import CONFIG_ENTRY_DATA, DATASET +from . import CONFIG_ENTRY_DATA, DATASET_CH16 from tests.common import MockConfigEntry @@ -21,7 +21,7 @@ async def otbr_config_entry_fixture(hass): ) config_entry.add_to_hass(hass) with patch( - "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "homeassistant.components.otbr.compute_pskc" ): # Patch to speed up tests diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index e27cfb219cf8..2ec79dcaeed8 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -11,6 +11,8 @@ from homeassistant.components import hassio, otbr from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import DATASET_CH15, DATASET_CH16 + from tests.common import MockConfigEntry, MockModule, mock_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -94,7 +96,10 @@ async def test_user_flow_router_not_setup( # Check we create a dataset and enable the router assert aioclient_mock.mock_calls[-2][0] == "POST" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" - assert aioclient_mock.mock_calls[-2][2] == {"NetworkName": "home-assistant"} + assert aioclient_mock.mock_calls[-2][2] == { + "Channel": 15, + "NetworkName": "home-assistant", + } assert aioclient_mock.mock_calls[-1][0] == "POST" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" @@ -226,7 +231,10 @@ 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] == "POST" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" - assert aioclient_mock.mock_calls[-2][2] == {"NetworkName": "home-assistant"} + assert aioclient_mock.mock_calls[-2][2] == { + "Channel": 15, + "NetworkName": "home-assistant", + } assert aioclient_mock.mock_calls[-1][0] == "POST" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" @@ -263,7 +271,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", - return_value="aa", + return_value=DATASET_CH15.hex(), ), patch( "homeassistant.components.otbr.async_setup_entry", return_value=True, @@ -275,7 +283,60 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( # 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" - assert aioclient_mock.mock_calls[-2][2] == "aa" + assert aioclient_mock.mock_calls[-2][2] == DATASET_CH15.hex() + + assert aioclient_mock.mock_calls[-1][0] == "POST" + assert aioclient_mock.mock_calls[-1][1].path == "/node/state" + assert aioclient_mock.mock_calls[-1][2] == "enable" + + expected_data = { + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", + } + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Open Thread Border Router" + assert result["data"] == expected_data + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + assert config_entry.options == {} + assert config_entry.title == "Open Thread Border Router" + assert config_entry.unique_id == otbr.DOMAIN + + +async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the hassio discovery flow when the border router has no dataset. + + This tests the behavior when the thread integration has a preferred dataset, but + the preferred dataset is not using channel 15. + """ + url = "http://core-silabs-multiprotocol:8081" + aioclient_mock.get(f"{url}/node/dataset/active", status=HTTPStatus.NO_CONTENT) + aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) + aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + + with patch( + "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", + return_value=DATASET_CH16.hex(), + ), patch( + "homeassistant.components.otbr.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + # Check we create a dataset and enable the router + assert aioclient_mock.mock_calls[-2][0] == "POST" + assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" + assert aioclient_mock.mock_calls[-2][2] == { + "Channel": 15, + "NetworkName": "home-assistant", + } assert aioclient_mock.mock_calls[-1][0] == "POST" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 9261004ec1c4..86443ce5c0c2 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -15,7 +15,7 @@ from homeassistant.helpers import issue_registry as ir from . import ( BASE_URL, CONFIG_ENTRY_DATA, - DATASET, + DATASET_CH16, DATASET_INSECURE_NW_KEY, DATASET_INSECURE_PASSPHRASE, ) @@ -36,13 +36,13 @@ async def test_import_dataset(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) with patch( - "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), 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(config_entry.title, DATASET.hex()) + mock_add.assert_called_once_with(config_entry.title, DATASET_CH16.hex()) assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" ) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index de01c6153e23..789356574312 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -124,7 +124,9 @@ async def test_create_network( assert msg["result"] is None create_dataset_mock.assert_called_once_with( - python_otbr_api.models.OperationalDataSet(network_name="home-assistant") + python_otbr_api.models.OperationalDataSet( + channel=15, network_name="home-assistant" + ) ) assert len(set_enabled_mock.mock_calls) == 2 assert set_enabled_mock.mock_calls[0][1][0] is False From c38df1102a63396906c4e5bc10b273a86ff063f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 17:34:46 +0100 Subject: [PATCH 0114/1058] Fix typo in thread (#88916) --- homeassistant/components/thread/websocket_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 5edea3a61ab1..053ec69a0faf 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -160,7 +160,7 @@ async def ws_discover_routers( @callback def router_removed(key: str) -> None: - """Forward router discovery or update to websocket.""" + """Forward router removed to websocket.""" connection.send_message( websocket_api.event_message( From 36e6a879ad3ba9ed6b0a0b7209e4e4a339e85b00 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Feb 2023 17:53:15 +0100 Subject: [PATCH 0115/1058] Bump aiohue library to version 4.6.2 (#88907) * Bump aiohue library to 4.6.2 * Fix long press (fixed in aiohue lib) * fix test --- homeassistant/components/hue/logbook.py | 1 + homeassistant/components/hue/manifest.json | 2 +- homeassistant/components/hue/v2/device_trigger.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hue/test_device_trigger_v2.py | 1 + 6 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/logbook.py b/homeassistant/components/hue/logbook.py index ce09c4c7ac90..21d0da074a72 100644 --- a/homeassistant/components/hue/logbook.py +++ b/homeassistant/components/hue/logbook.py @@ -35,6 +35,7 @@ TRIGGER_TYPE = { "remote_double_button_long_press": "both {subtype} released after long press", "remote_double_button_short_press": "both {subtype} released", "initial_press": "{subtype} pressed initially", + "long_press": "{subtype} long press", "repeat": "{subtype} held down", "short_release": "{subtype} released after short press", "long_release": "{subtype} released after long press", diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 7c6adc30f9e7..e55bd2782dfd 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,6 @@ "iot_class": "local_push", "loggers": ["aiohue"], "quality_scale": "platinum", - "requirements": ["aiohue==4.6.1"], + "requirements": ["aiohue==4.6.2"], "zeroconf": ["_hue._tcp.local."] } diff --git a/homeassistant/components/hue/v2/device_trigger.py b/homeassistant/components/hue/v2/device_trigger.py index 538509ed5ce2..466b593b56aa 100644 --- a/homeassistant/components/hue/v2/device_trigger.py +++ b/homeassistant/components/hue/v2/device_trigger.py @@ -46,6 +46,7 @@ DEFAULT_BUTTON_EVENT_TYPES = ( ButtonEvent.INITIAL_PRESS, ButtonEvent.REPEAT, ButtonEvent.SHORT_RELEASE, + ButtonEvent.LONG_PRESS, ButtonEvent.LONG_RELEASE, ) diff --git a/requirements_all.txt b/requirements_all.txt index 37e8ce7c8299..0cd38f0f1c9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -181,7 +181,7 @@ aiohomekit==2.6.1 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.1 +aiohue==4.6.2 # homeassistant.components.imap aioimaplib==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6df57112a3b..6de4fcb1f184 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -165,7 +165,7 @@ aiohomekit==2.6.1 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.1 +aiohue==4.6.2 # homeassistant.components.imap aioimaplib==1.0.1 diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index cb8454743276..81410b0658fd 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -84,6 +84,7 @@ async def test_get_triggers( } for event_type in ( ButtonEvent.INITIAL_PRESS, + ButtonEvent.LONG_PRESS, ButtonEvent.LONG_RELEASE, ButtonEvent.REPEAT, ButtonEvent.SHORT_RELEASE, From ee144d34a95f4365f44a53ddc51c95343ec6fd52 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Feb 2023 18:03:36 +0100 Subject: [PATCH 0116/1058] Adjust core test to avoid lingering task (#88918) --- tests/test_core.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_core.py b/tests/test_core.py index eb81efae9200..f627475270f4 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2034,7 +2034,8 @@ async def test_shutdown_does_not_block_on_shielded_tasks( ) -> None: """Ensure shutdown does not block on shielded tasks.""" result = asyncio.Future() - shielded_task = asyncio.shield(asyncio.sleep(10)) + sleep_task = asyncio.ensure_future(asyncio.sleep(10)) + shielded_task = asyncio.shield(sleep_task) async def test_task(): try: @@ -2050,3 +2051,6 @@ async def test_shutdown_does_not_block_on_shielded_tasks( assert result.done() assert task.done() assert time.monotonic() - start < 0.5 + + # Cleanup lingering task after test is done + sleep_task.cancel() From ac6bbc2f1c6e1e32c3f2c9456c89fe92771b3e0c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Feb 2023 18:04:40 +0100 Subject: [PATCH 0117/1058] Add missing mock in webostv config flow tests (#88913) --- tests/components/webostv/conftest.py | 10 ++++++++++ tests/components/webostv/test_config_flow.py | 18 +++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/components/webostv/conftest.py b/tests/components/webostv/conftest.py index 5a55ac492df3..b78046c22ec2 100644 --- a/tests/components/webostv/conftest.py +++ b/tests/components/webostv/conftest.py @@ -1,4 +1,5 @@ """Common fixtures and objects for the LG webOS integration tests.""" +from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch import pytest @@ -10,6 +11,15 @@ from .const import CHANNEL_1, CHANNEL_2, CLIENT_KEY, FAKE_UUID, MOCK_APPS, MOCK_ from tests.common import async_mock_service +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.webostv.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def calls(hass): """Track calls to a mock service.""" diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index cc588c9c217f..ad57b4647ea3 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -1,6 +1,6 @@ """Test the WebOS Tv config flow.""" import dataclasses -from unittest.mock import Mock, patch +from unittest.mock import Mock from aiowebostv import WebOsTvPairError import pytest @@ -16,6 +16,8 @@ from homeassistant.data_entry_flow import FlowResultType from . import setup_webostv from .const import CLIENT_KEY, FAKE_UUID, HOST, MOCK_APPS, MOCK_INPUTS, TV_NAME +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + MOCK_USER_CONFIG = { CONF_HOST: HOST, CONF_NAME: TV_NAME, @@ -65,10 +67,9 @@ async def test_form(hass: HomeAssistant, client) -> None: assert result["type"] == FlowResultType.FORM assert result["step_id"] == "pairing" - with patch("homeassistant.components.webostv.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) await hass.async_block_till_done() @@ -184,10 +185,9 @@ async def test_form_ssdp(hass: HomeAssistant, client) -> None: """Test that the ssdp confirmation form is served.""" assert client - with patch("homeassistant.components.webostv.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVERY_INFO + ) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM From 7bfc7f134cd6c7912a0e5d11f014cd3cb2e99e03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 18:06:40 +0100 Subject: [PATCH 0118/1058] Reset state of template cover on error (#88915) --- homeassistant/components/template/cover.py | 3 +++ tests/components/template/test_cover.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 9aafc719f1bb..1e0fdfacc8e6 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -233,6 +233,9 @@ class CoverTemplate(TemplateEntity, CoverEntity): if not self._position_template: self._position = None + self._is_opening = False + self._is_closing = False + @callback def _update_position(self, result): try: diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index e89773d9988a..acf49eb5469a 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -72,7 +72,7 @@ OPEN_CLOSE_COVER_CONFIG = { ( "cover.test_state", "dog", - STATE_CLOSING, + STATE_UNKNOWN, {}, -1, "Received invalid cover is_on state: dog", From 83214431936b001cf60005a58655f43d6fd61748 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 18:07:01 +0100 Subject: [PATCH 0119/1058] Fix Dormakaba dKey binary sensor (#88922) --- homeassistant/components/dormakaba_dkey/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 1f2d83a2582f..2f57d9802b90 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS from .models import DormakabaDkeyData -PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) From 95ed6fbc082ccd269211a0bceb5b4fa9994bf245 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Feb 2023 18:07:17 +0100 Subject: [PATCH 0120/1058] Small improvements to middleware filter (#88921) Small improvements middleware filter --- homeassistant/components/http/security_filter.py | 13 ++++++++++--- tests/components/http/test_security_filter.py | 12 +++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index 57ae90631709..a9b32bd7f4c8 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -5,6 +5,7 @@ from collections.abc import Awaitable, Callable import logging import re from typing import Final +from urllib.parse import unquote from aiohttp.web import Application, HTTPBadRequest, Request, StreamResponse, middleware @@ -39,18 +40,24 @@ FILTERS: Final = re.compile( def setup_security_filter(app: Application) -> None: """Create security filter middleware for the app.""" + def _recursive_unquote(value: str) -> str: + """Handle values that are encoded multiple times.""" + if (unquoted := unquote(value)) != value: + unquoted = _recursive_unquote(unquoted) + return unquoted + @middleware async def security_filter_middleware( request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: - """Process request and tblock commonly known exploit attempts.""" - if FILTERS.search(request.path): + """Process request and block commonly known exploit attempts.""" + if FILTERS.search(_recursive_unquote(request.path)): _LOGGER.warning( "Filtered a potential harmful request to: %s", request.raw_path ) raise HTTPBadRequest - if FILTERS.search(request.query_string): + if FILTERS.search(_recursive_unquote(request.query_string)): _LOGGER.warning( "Filtered a request with a potential harmful query string: %s", request.raw_path, diff --git a/tests/components/http/test_security_filter.py b/tests/components/http/test_security_filter.py index 82e8382461b0..1c139a591611 100644 --- a/tests/components/http/test_security_filter.py +++ b/tests/components/http/test_security_filter.py @@ -49,7 +49,17 @@ async def test_ok_requests( ("/", {"test": "test/../../api"}, True), ("/", {"test": "/test/%2E%2E%2f%2E%2E%2fapi"}, True), ("/", {"test": "test/%2E%2E%2f%2E%2E%2fapi"}, True), + ("/", {"test": "test/%252E%252E/api"}, True), + ("/", {"test": "test/%252E%252E%2fapi"}, True), + ( + "/", + {"test": "test/%2525252E%2525252E%2525252f%2525252E%2525252E%2525252fapi"}, + True, + ), + ("/test/.%252E/api", {}, False), + ("/test/%252E%252E/api", {}, False), ("/test/%2E%2E%2f%2E%2E%2fapi", {}, False), + ("/test/%2525252E%2525252E%2525252f%2525252E%2525252E/api", {}, False), ("/", {"sql": ";UNION SELECT (a, b"}, True), ("/", {"sql": "UNION%20SELECT%20%28a%2C%20b"}, True), ("/UNION%20SELECT%20%28a%2C%20b", {}, False), @@ -87,7 +97,7 @@ async def test_bad_requests( None, http.request, "GET", - f"http://{mock_api_client.host}:{mock_api_client.port}/{request_path}{man_params}", + f"http://{mock_api_client.host}:{mock_api_client.port}{request_path}{man_params}", request_params, ) From c444e1c860433d03bceece75525c86d4748a6b87 Mon Sep 17 00:00:00 2001 From: b-uwe <61052367+b-uwe@users.noreply.github.com> Date: Tue, 28 Feb 2023 18:09:52 +0100 Subject: [PATCH 0121/1058] Add virtual integration for HELTUN (#88892) --- homeassistant/brands/heltun.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/heltun.json diff --git a/homeassistant/brands/heltun.json b/homeassistant/brands/heltun.json new file mode 100644 index 000000000000..d9e85a89542f --- /dev/null +++ b/homeassistant/brands/heltun.json @@ -0,0 +1,5 @@ +{ + "domain": "heltun", + "name": "HELTUN", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0f5e0ff08e26..6111681bd35d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2202,6 +2202,12 @@ "integration_type": "virtual", "supported_by": "gree" }, + "heltun": { + "name": "HELTUN", + "iot_standards": [ + "zwave" + ] + }, "here_travel_time": { "name": "HERE Travel Time", "integration_type": "hub", From 69ce6980d6e4ebab73907a0a7094f28cf37f38bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 19:35:43 +0100 Subject: [PATCH 0122/1058] Add number + sensor device class energy storage (#88310) * Add number + sensor device class energy storage * Format code * Update device automations --- homeassistant/components/number/const.py | 10 ++++++++++ homeassistant/components/sensor/const.py | 14 ++++++++++++++ .../components/sensor/device_condition.py | 1 + homeassistant/components/sensor/device_trigger.py | 1 + tests/components/sensor/test_device_condition.py | 2 ++ tests/components/sensor/test_device_trigger.py | 2 ++ 6 files changed, 30 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 91c1306c2261..aa57bb2b7162 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -127,6 +127,15 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` """ + ENERGY_STORAGE = "energy_storage" + """Stored energy. + + Use this device class for sensors measuring stored energy, for example the amount + of electric energy currently stored in a battery or the capacity of a battery. + + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + FREQUENCY = "frequency" """Frequency. @@ -365,6 +374,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.DATA_SIZE: set(UnitOfInformation), NumberDeviceClass.DISTANCE: set(UnitOfLength), NumberDeviceClass.ENERGY: set(UnitOfEnergy), + NumberDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), NumberDeviceClass.FREQUENCY: set(UnitOfFrequency), NumberDeviceClass.GAS: { UnitOfVolume.CENTUM_CUBIC_FEET, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 58cf985b09f4..8ded1c304da5 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -160,6 +160,17 @@ class SensorDeviceClass(StrEnum): ENERGY = "energy" """Energy. + Use this device class for sensors measuring energy consumption, for example + electric energy consumption. + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + + ENERGY_STORAGE = "energy_storage" + """Stored energy. + + Use this device class for sensors measuring stored energy, for example the amount + of electric energy currently stored in a battery or the capacity of a battery. + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` """ @@ -429,6 +440,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.DATA_SIZE: InformationConverter, SensorDeviceClass.DISTANCE: DistanceConverter, SensorDeviceClass.ENERGY: EnergyConverter, + SensorDeviceClass.ENERGY_STORAGE: EnergyConverter, SensorDeviceClass.GAS: VolumeConverter, SensorDeviceClass.POWER: PowerConverter, SensorDeviceClass.POWER_FACTOR: UnitlessRatioConverter, @@ -462,6 +474,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.SECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), + SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), SensorDeviceClass.FREQUENCY: set(UnitOfFrequency), SensorDeviceClass.GAS: { UnitOfVolume.CENTUM_CUBIC_FEET, @@ -526,6 +539,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.ENERGY_STORAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ENUM: set(), SensorDeviceClass.FREQUENCY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.GAS: {SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 5746b8b8e8cb..6ed47cbf63e1 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -89,6 +89,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_IS_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_IS_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}], + SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_IS_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_IS_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_IS_GAS}], SensorDeviceClass.HUMIDITY: [{CONF_TYPE: CONF_IS_HUMIDITY}], diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index dfd0a576d212..7e498321b3ec 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -88,6 +88,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.DISTANCE: [{CONF_TYPE: CONF_DISTANCE}], SensorDeviceClass.DURATION: [{CONF_TYPE: CONF_DURATION}], SensorDeviceClass.ENERGY: [{CONF_TYPE: CONF_ENERGY}], + SensorDeviceClass.ENERGY_STORAGE: [{CONF_TYPE: CONF_ENERGY}], SensorDeviceClass.FREQUENCY: [{CONF_TYPE: CONF_FREQUENCY}], SensorDeviceClass.GAS: [{CONF_TYPE: CONF_GAS}], SensorDeviceClass.HUMIDITY: [{CONF_TYPE: CONF_HUMIDITY}], diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 02b369b6a677..24d480e24b38 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -51,12 +51,14 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.BATTERY: "CONF_IS_BATTERY_LEVEL", SensorDeviceClass.CO: "CONF_IS_CO", SensorDeviceClass.CO2: "CONF_IS_CO2", + SensorDeviceClass.ENERGY_STORAGE: "CONF_IS_ENERGY", }.get(device_class, f"CONF_IS_{device_class.value.upper()}") assert hasattr(device_condition, constant_name), f"Missing constant {constant_name}" # Ensure it has correct value constant_value = { SensorDeviceClass.BATTERY: "is_battery_level", + SensorDeviceClass.ENERGY_STORAGE: "is_energy", }.get(device_class, f"is_{device_class.value}") assert getattr(device_condition, constant_name) == constant_value diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 298aab2953a6..34b5d6fb40fd 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -55,12 +55,14 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.BATTERY: "CONF_BATTERY_LEVEL", SensorDeviceClass.CO: "CONF_CO", SensorDeviceClass.CO2: "CONF_CO2", + SensorDeviceClass.ENERGY_STORAGE: "CONF_ENERGY", }.get(device_class, f"CONF_{device_class.value.upper()}") assert hasattr(device_trigger, constant_name), f"Missing constant {constant_name}" # Ensure it has correct value constant_value = { SensorDeviceClass.BATTERY: "battery_level", + SensorDeviceClass.ENERGY_STORAGE: "energy", }.get(device_class, device_class.value) assert getattr(device_trigger, constant_name) == constant_value From ad55a5db11e36926cbb5098e53ac32ca57d45265 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 21:33:50 +0100 Subject: [PATCH 0123/1058] Bump py-dormakaba-dkey to 1.0.3 (#88924) * Bump py-dormakaba-dkey to 1.0.3 * Log unexpected errors in config flow --- homeassistant/components/dormakaba_dkey/config_flow.py | 3 ++- homeassistant/components/dormakaba_dkey/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index dca19c802b1b..3da1fd841fd4 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -132,7 +132,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: association_data = await lock.associate(user_input["activation_code"]) - except BleakError: + except BleakError as err: + _LOGGER.warning("BleakError", exc_info=err) return self.async_abort(reason="cannot_connect") except dkey_errors.InvalidActivationCode: errors["base"] = "invalid_code" diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index 206e575b7ac6..b837cf8dfed5 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-dormakaba-dkey==1.0.2"] + "requirements": ["py-dormakaba-dkey==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0cd38f0f1c9c..bc86f5041c40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1430,7 +1430,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.2 +py-dormakaba-dkey==1.0.3 # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6de4fcb1f184..a82f75332613 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1045,7 +1045,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.2 +py-dormakaba-dkey==1.0.3 # homeassistant.components.melissa py-melissa-climate==2.1.4 From 47a3c27c9a524edd823cd80697ae3fad39e7a23a Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 28 Feb 2023 15:34:07 -0500 Subject: [PATCH 0124/1058] Bump pyinsteon to 1.3.3 (#88925) Bump pyinsteon --- 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 35d7f624c513..40316a6ba3ec 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.3.2", + "pyinsteon==1.3.3", "insteon-frontend-home-assistant==0.3.2" ], "usb": [ diff --git a/requirements_all.txt b/requirements_all.txt index bc86f5041c40..fccda2618d92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1687,7 +1687,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.2 +pyinsteon==1.3.3 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a82f75332613..42f03d72fe2c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1212,7 +1212,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.2 +pyinsteon==1.3.3 # homeassistant.components.ipma pyipma==3.0.6 From dccd3e277ef190d07aef37177e9c92b1fa33bef1 Mon Sep 17 00:00:00 2001 From: djtimca <60706061+djtimca@users.noreply.github.com> Date: Tue, 28 Feb 2023 17:33:05 -0500 Subject: [PATCH 0125/1058] Bump auroranoaa to 0.0.3 (#88927) * Bump aurora_api version to fix issues with NOAA conversion values. Fix #82587 * update requirements for aurora. * Add state_class to aurora sensor. * Fixed environment to run requirements_all script. * Revert "Add state_class to aurora sensor." This reverts commit 213e21e8424aafd50242e77bcedc39f0a4b50074. --- homeassistant/components/aurora/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aurora/manifest.json b/homeassistant/components/aurora/manifest.json index a5bb33273328..018e8ab8135a 100644 --- a/homeassistant/components/aurora/manifest.json +++ b/homeassistant/components/aurora/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aurora", "iot_class": "cloud_polling", "loggers": ["auroranoaa"], - "requirements": ["auroranoaa==0.0.2"] + "requirements": ["auroranoaa==0.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index fccda2618d92..619e5cc014b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -383,7 +383,7 @@ asyncsleepiq==1.2.3 atenpdu==0.3.2 # homeassistant.components.aurora -auroranoaa==0.0.2 +auroranoaa==0.0.3 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42f03d72fe2c..b0191b62c362 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -334,7 +334,7 @@ async-upnp-client==0.33.1 asyncsleepiq==1.2.3 # homeassistant.components.aurora -auroranoaa==0.0.2 +auroranoaa==0.0.3 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.7 From c5e39f7039d5a276376d84782175a95fff22aeb9 Mon Sep 17 00:00:00 2001 From: djtimca <60706061+djtimca@users.noreply.github.com> Date: Tue, 28 Feb 2023 17:47:29 -0500 Subject: [PATCH 0126/1058] Add state class to Aurora (#88938) * Bump aurora_api version to fix issues with NOAA conversion values. Fix #82587 * update requirements for aurora. * Add state_class to aurora sensor. * Fixed environment to run requirements_all script. * Revert "Add state_class to aurora sensor." This reverts commit 213e21e8424aafd50242e77bcedc39f0a4b50074. * Add state class to aurora sensor. * Revert "Fixed environment to run requirements_all script." This reverts commit f3f624226ee2d1853c4a6220dfa6456b4d86da5c. * Revert "update requirements for aurora." This reverts commit a3546ad88d33e127e84030764d0e3e40401a8865. * Revert "Bump aurora_api version to fix issues with NOAA conversion values. Fix #82587" This reverts commit faf3ba7b5b25aeedb5041f196de84e4fa55d4c89. * Move state class to _attr_state_class. --- homeassistant/components/aurora/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index 2710badb516c..de5e566e2680 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -1,5 +1,5 @@ """Support for Aurora Forecast sensor.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant @@ -28,6 +28,7 @@ class AuroraSensor(AuroraEntity, SensorEntity): """Implementation of an aurora sensor.""" _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT @property def native_value(self): From e0bdb3ecc3aca3489176b860c3efaec9d7d523eb Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Feb 2023 20:07:18 -0600 Subject: [PATCH 0127/1058] Update intent sentences package (#88933) * Actually use translated state names in response * Change test result now that locks are excluded from HassTurnOn * Bump home-assistant-intents and hassil versions --- homeassistant/components/conversation/default_agent.py | 4 ++-- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/helpers/test_intent.py | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 3be3f8cfc6fa..78002b42f696 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -257,9 +257,9 @@ class DefaultAgent(AbstractConversationAgent): # This is available in the response template as "state". state1: core.State | None = None if intent_response.matched_states: - state1 = intent_response.matched_states[0] + state1 = matched[0] elif intent_response.unmatched_states: - state1 = intent_response.unmatched_states[0] + state1 = unmatched[0] # Render response template speech = response_template.async_render( diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 5e4e2e8902e3..7630eed01f19 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.0.5", "home-assistant-intents==2023.2.22"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.2.28"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 16b1969f61be..4a00b05d21b8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,10 +21,10 @@ cryptography==39.0.1 dbus-fast==1.84.1 fnvhash==0.1.0 hass-nabucasa==0.61.0 -hassil==1.0.5 +hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230227.0 -home-assistant-intents==2023.2.22 +home-assistant-intents==2023.2.28 httpx==0.23.3 ifaddr==0.1.7 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 619e5cc014b2..58eb01371bf5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -874,7 +874,7 @@ hass-nabucasa==0.61.0 hass_splunk==0.1.1 # homeassistant.components.conversation -hassil==1.0.5 +hassil==1.0.6 # homeassistant.components.tasmota hatasmota==0.6.4 @@ -910,7 +910,7 @@ holidays==0.18.0 home-assistant-frontend==20230227.0 # homeassistant.components.conversation -home-assistant-intents==2023.2.22 +home-assistant-intents==2023.2.28 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0191b62c362..7e23d75ecb2d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -666,7 +666,7 @@ habitipy==0.2.0 hass-nabucasa==0.61.0 # homeassistant.components.conversation -hassil==1.0.5 +hassil==1.0.6 # homeassistant.components.tasmota hatasmota==0.6.4 @@ -693,7 +693,7 @@ holidays==0.18.0 home-assistant-frontend==20230227.0 # homeassistant.components.conversation -home-assistant-intents==2023.2.22 +home-assistant-intents==2023.2.28 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 9ea95231b2f5..7211f2bb9b4e 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -173,4 +173,4 @@ 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.FAILED_TO_HANDLE + assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH From 8f6cfc25c0fa2e7fab3bee0113e6fd794c4d0b31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Feb 2023 20:09:47 -0600 Subject: [PATCH 0128/1058] Use ulid-transform for constructing ulids (#88939) * Use ulid-transform for constructing ulids A future PR will use the new library to reduce the storage overhead of ulids in the database * tweak * tweak * bump --- homeassistant/core.py | 4 +- homeassistant/package_constraints.txt | 1 + homeassistant/util/ulid.py | 53 ++------------------------- pyproject.toml | 1 + requirements.txt | 1 + 5 files changed, 8 insertions(+), 52 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 7003b87ce677..b2525e2f096e 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -871,7 +871,7 @@ class Event: self.origin = origin self.time_fired = time_fired or dt_util.utcnow() self.context: Context = context or Context( - id=ulid_util.ulid(dt_util.utc_to_timestamp(self.time_fired)) + id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) ) def as_dict(self) -> dict[str, Any]: @@ -1533,7 +1533,7 @@ class StateMachine: now = dt_util.utcnow() if context is None: - context = Context(id=ulid_util.ulid(dt_util.utc_to_timestamp(now))) + context = Context(id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(now))) state = State( entity_id, new_state, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4a00b05d21b8..5d05bec39c42 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,6 +44,7 @@ requests==2.28.1 scapy==2.5.0 sqlalchemy==2.0.4 typing-extensions>=4.5.0,<5.0 +ulid-transform==0.3.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index d40b0f48e166..304a42ec6105 100644 --- a/homeassistant/util/ulid.py +++ b/homeassistant/util/ulid.py @@ -1,21 +1,11 @@ """Helpers to generate ulids.""" from __future__ import annotations -from random import getrandbits import time +from ulid_transform import ulid_at_time, ulid_hex -def ulid_hex() -> str: - """Generate a ULID in lowercase hex that will work for a UUID. - - This ulid should not be used for cryptographically secure - operations. - - This string can be converted with https://github.com/ahawker/ulid - - ulid.from_uuid(uuid.UUID(ulid_hex)) - """ - return f"{int(time.time()*1000):012x}{getrandbits(80):020x}" +__all__ = ["ulid", "ulid_hex", "ulid_at_time"] def ulid(timestamp: float | None = None) -> str: @@ -35,41 +25,4 @@ def ulid(timestamp: float | None = None) -> str: import ulid ulid.parse(ulid_util.ulid()) """ - ulid_bytes = int((timestamp or time.time()) * 1000).to_bytes( - 6, byteorder="big" - ) + int(getrandbits(80)).to_bytes(10, byteorder="big") - - # This is base32 crockford encoding with the loop unrolled for performance - # - # This code is adapted from: - # https://github.com/ahawker/ulid/blob/06289583e9de4286b4d80b4ad000d137816502ca/ulid/base32.py#L102 - # - enc = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" - return ( - enc[(ulid_bytes[0] & 224) >> 5] - + enc[ulid_bytes[0] & 31] - + enc[(ulid_bytes[1] & 248) >> 3] - + enc[((ulid_bytes[1] & 7) << 2) | ((ulid_bytes[2] & 192) >> 6)] - + enc[((ulid_bytes[2] & 62) >> 1)] - + enc[((ulid_bytes[2] & 1) << 4) | ((ulid_bytes[3] & 240) >> 4)] - + enc[((ulid_bytes[3] & 15) << 1) | ((ulid_bytes[4] & 128) >> 7)] - + enc[(ulid_bytes[4] & 124) >> 2] - + enc[((ulid_bytes[4] & 3) << 3) | ((ulid_bytes[5] & 224) >> 5)] - + enc[ulid_bytes[5] & 31] - + enc[(ulid_bytes[6] & 248) >> 3] - + enc[((ulid_bytes[6] & 7) << 2) | ((ulid_bytes[7] & 192) >> 6)] - + enc[(ulid_bytes[7] & 62) >> 1] - + enc[((ulid_bytes[7] & 1) << 4) | ((ulid_bytes[8] & 240) >> 4)] - + enc[((ulid_bytes[8] & 15) << 1) | ((ulid_bytes[9] & 128) >> 7)] - + enc[(ulid_bytes[9] & 124) >> 2] - + enc[((ulid_bytes[9] & 3) << 3) | ((ulid_bytes[10] & 224) >> 5)] - + enc[ulid_bytes[10] & 31] - + enc[(ulid_bytes[11] & 248) >> 3] - + enc[((ulid_bytes[11] & 7) << 2) | ((ulid_bytes[12] & 192) >> 6)] - + enc[(ulid_bytes[12] & 62) >> 1] - + enc[((ulid_bytes[12] & 1) << 4) | ((ulid_bytes[13] & 240) >> 4)] - + enc[((ulid_bytes[13] & 15) << 1) | ((ulid_bytes[14] & 128) >> 7)] - + enc[(ulid_bytes[14] & 124) >> 2] - + enc[((ulid_bytes[14] & 3) << 3) | ((ulid_bytes[15] & 224) >> 5)] - + enc[ulid_bytes[15] & 31] - ) + return ulid_at_time(timestamp or time.time()) diff --git a/pyproject.toml b/pyproject.toml index a3d6d2f2446f..7a0aff9ce9a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.1", "typing-extensions>=4.5.0,<5.0", + "ulid-transform==0.3.1", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.8.1", diff --git a/requirements.txt b/requirements.txt index aa6e85d15200..31831a93e2a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.1 typing-extensions>=4.5.0,<5.0 +ulid-transform==0.3.1 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.8.1 From 39f5f0946e1d7c2e2fcf36fb8fdaa7b81fca9eb8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 03:15:44 +0100 Subject: [PATCH 0129/1058] Store source entity in switch_as_x entity options (#88914) --- .../components/switch_as_x/entity.py | 11 ++++-- tests/components/switch_as_x/test_init.py | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index bc24460a105a..ac56b4c6078c 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -17,6 +17,8 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_track_state_change_event +from .const import DOMAIN as SWITCH_AS_X_DOMAIN + class BaseEntity(Entity): """Represents a Switch as a X.""" @@ -28,8 +30,8 @@ class BaseEntity(Entity): name: str, switch_entity_id: str, unique_id: str | None, - device_id: str | None = None, - entity_category: EntityCategory | None = None, + device_id: str | None, + entity_category: EntityCategory | None, ) -> None: """Initialize Light Switch.""" self._device_id = device_id @@ -71,6 +73,11 @@ class BaseEntity(Entity): registry = er.async_get(self.hass) if registry.async_get(self.entity_id) is not None: registry.async_update_entity(self.entity_id, device_id=self._device_id) + registry.async_update_entity_options( + self.entity_id, + SWITCH_AS_X_DOMAIN, + {"entity_id": self._switch_entity_id}, + ) class BaseToggleEntity(BaseEntity, ToggleEntity): diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 964e0a0d4334..a95725999d92 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -438,3 +438,39 @@ async def test_entity_category_inheritance( assert entity_entry assert entity_entry.device_id == switch_entity_entry.device_id assert entity_entry.entity_category is EntityCategory.CONFIG + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_entity_options( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test the source entity is stored as an entity option.""" + registry = er.async_get(hass) + + switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") + registry.async_update_entity( + switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG + ) + + # Add the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + 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() + + entity_entry = registry.async_get(f"{target_domain}.abc") + 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} + } From 86acc4262e07e61da0047fbaf1f0f008a27adc60 Mon Sep 17 00:00:00 2001 From: Volker Stolz Date: Wed, 1 Mar 2023 03:52:45 +0100 Subject: [PATCH 0130/1058] Introduce a UUID configuration option for API token (#88765) * Introduce a UUID configuration option for API token. (#86547) If the uuid is configured, it will be used in the HTTP headers. Otherwise, we'll hash the salted instance URL which should be good enough(tm). * Generate random 6-digit uuid on startup. --- homeassistant/components/entur_public_transport/sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index 3e8b7bbe3907..f5a954b16d41 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta +from random import randint from enturclient import EnturPublicTransportData import voluptuous as vol @@ -22,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle import homeassistant.util.dt as dt_util -API_CLIENT_NAME = "homeassistant-homeassistant" +API_CLIENT_NAME = "homeassistant-{}" CONF_STOP_IDS = "stop_ids" CONF_EXPAND_PLATFORMS = "expand_platforms" @@ -105,7 +106,7 @@ async def async_setup_platform( quays = [s for s in stop_ids if "Quay" in s] data = EnturPublicTransportData( - API_CLIENT_NAME, + API_CLIENT_NAME.format(str(randint(100000, 999999))), stops=stops, quays=quays, line_whitelist=line_whitelist, From 6ab0b2751dedf2bdf0b05f1a964cf611ef18ef51 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:55:44 +0100 Subject: [PATCH 0131/1058] Adjust issue_registry imports (#88878) * Add issue_registry to RUFF extend aliases * Adjust code accordingly * Revert "Add issue_registry to RUFF extend aliases" This reverts commit 4e73dd567be42c74d0db4a51bac8d7aa9d7c93e7. * Revert changes to common.py --- homeassistant/components/bayesian/repairs.py | 10 ++--- homeassistant/components/obihai/sensor.py | 6 +-- .../components/repairs/test_websocket_api.py | 10 ++--- tests/helpers/test_issue_registry.py | 38 +++++++++---------- 4 files changed, 30 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/bayesian/repairs.py b/homeassistant/components/bayesian/repairs.py index 9a527636948c..47d7dff6e19a 100644 --- a/homeassistant/components/bayesian/repairs.py +++ b/homeassistant/components/bayesian/repairs.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry +from homeassistant.helpers import issue_registry as ir from . import DOMAIN from .helpers import Observation @@ -15,13 +15,13 @@ def raise_mirrored_entries( if len(observations) != 2: return if observations[0].is_mirror(observations[1]): - issue_registry.async_create_issue( + ir.async_create_issue( hass, DOMAIN, "mirrored_entry/" + text, breaks_in_ha_version="2022.10.0", is_fixable=False, - severity=issue_registry.IssueSeverity.WARNING, + severity=ir.IssueSeverity.WARNING, translation_key="manual_migration", translation_placeholders={"entity": text}, learn_more_url="https://github.com/home-assistant/core/pull/67631", @@ -31,13 +31,13 @@ def raise_mirrored_entries( # Should deprecate in some future version (2022.10 at time of writing) & make prob_given_false required in schemas. def raise_no_prob_given_false(hass: HomeAssistant, text: str) -> None: """In previous 2022.9 and earlier, prob_given_false was optional and had a default version.""" - issue_registry.async_create_issue( + ir.async_create_issue( hass, DOMAIN, f"no_prob_given_false/{text}", breaks_in_ha_version="2022.10.0", is_fixable=False, - severity=issue_registry.IssueSeverity.ERROR, + severity=ir.IssueSeverity.ERROR, translation_key="no_prob_given_false", translation_placeholders={"entity": text}, learn_more_url="https://github.com/home-assistant/core/pull/67631", diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 953193a5ab66..4f7b6195e4e3 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -40,13 +40,13 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Obihai sensor platform.""" - issue_registry.async_create_issue( + ir.async_create_issue( hass, DOMAIN, "manual_migration", breaks_in_ha_version="2023.6.0", is_fixable=False, - severity=issue_registry.IssueSeverity.ERROR, + severity=ir.IssueSeverity.ERROR, translation_key="manual_migration", ) diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 0e12bf0562c1..be50dba14b38 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -16,7 +16,7 @@ from homeassistant.components.repairs import RepairsFlow from homeassistant.components.repairs.const import DOMAIN from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockUser, mock_platform @@ -53,7 +53,7 @@ async def create_issues(hass, ws_client, issues=None): issues = DEFAULT_ISSUES for issue in issues: - issue_registry.async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], @@ -439,8 +439,8 @@ async def test_list_issues( """Test we can list issues.""" # Add an inactive issue, this should not be exposed in the list - hass_storage[issue_registry.STORAGE_KEY] = { - "version": issue_registry.STORAGE_VERSION_MAJOR, + hass_storage[ir.STORAGE_KEY] = { + "version": ir.STORAGE_VERSION_MAJOR, "data": { "issues": [ { @@ -491,7 +491,7 @@ async def test_list_issues( ] for issue in issues: - issue_registry.async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], diff --git a/tests/helpers/test_issue_registry.py b/tests/helpers/test_issue_registry.py index 21600b776c14..51cffbc78100 100644 --- a/tests/helpers/test_issue_registry.py +++ b/tests/helpers/test_issue_registry.py @@ -4,7 +4,7 @@ from typing import Any import pytest from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry +from homeassistant.helpers import issue_registry as ir from tests.common import async_capture_events, flush_store @@ -59,12 +59,10 @@ async def test_load_issues(hass: HomeAssistant) -> None: }, ] - events = async_capture_events( - hass, issue_registry.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED - ) + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) for issue in issues: - issue_registry.async_create_issue( + ir.async_create_issue( hass, issue["domain"], issue["issue_id"], @@ -101,9 +99,7 @@ async def test_load_issues(hass: HomeAssistant) -> None: "issue_id": "issue_4", } - issue_registry.async_ignore_issue( - hass, issues[0]["domain"], issues[0]["issue_id"], True - ) + ir.async_ignore_issue(hass, issues[0]["domain"], issues[0]["issue_id"], True) await hass.async_block_till_done() assert len(events) == 5 @@ -113,7 +109,7 @@ async def test_load_issues(hass: HomeAssistant) -> None: "issue_id": "issue_1", } - issue_registry.async_delete_issue(hass, issues[2]["domain"], issues[2]["issue_id"]) + ir.async_delete_issue(hass, issues[2]["domain"], issues[2]["issue_id"]) await hass.async_block_till_done() assert len(events) == 6 @@ -123,20 +119,20 @@ async def test_load_issues(hass: HomeAssistant) -> None: "issue_id": "issue_3", } - registry: issue_registry.IssueRegistry = hass.data[issue_registry.DATA_REGISTRY] + registry: ir.IssueRegistry = hass.data[ir.DATA_REGISTRY] assert len(registry.issues) == 3 issue1 = registry.async_get_issue("test", "issue_1") issue2 = registry.async_get_issue("test", "issue_2") issue4 = registry.async_get_issue("test", "issue_4") - registry2 = issue_registry.IssueRegistry(hass) + registry2 = ir.IssueRegistry(hass) await flush_store(registry._store) await registry2.async_load() assert list(registry.issues) == list(registry2.issues) issue1_registry2 = registry2.async_get_issue("test", "issue_1") - assert issue1_registry2 == issue_registry.IssueEntry( + assert issue1_registry2 == ir.IssueEntry( active=False, breaks_in_ha_version=None, created=issue1.created, @@ -153,7 +149,7 @@ async def test_load_issues(hass: HomeAssistant) -> None: translation_placeholders=None, ) issue2_registry2 = registry2.async_get_issue("test", "issue_2") - assert issue2_registry2 == issue_registry.IssueEntry( + assert issue2_registry2 == ir.IssueEntry( active=False, breaks_in_ha_version=None, created=issue2.created, @@ -178,9 +174,9 @@ async def test_loading_issues_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored issues on start.""" - hass_storage[issue_registry.STORAGE_KEY] = { - "version": issue_registry.STORAGE_VERSION_MAJOR, - "minor_version": issue_registry.STORAGE_VERSION_MINOR, + hass_storage[ir.STORAGE_KEY] = { + "version": ir.STORAGE_VERSION_MAJOR, + "minor_version": ir.STORAGE_VERSION_MINOR, "data": { "issues": [ { @@ -216,16 +212,16 @@ async def test_loading_issues_from_storage( }, } - await issue_registry.async_load(hass) + await ir.async_load(hass) - registry: issue_registry.IssueRegistry = hass.data[issue_registry.DATA_REGISTRY] + registry: ir.IssueRegistry = hass.data[ir.DATA_REGISTRY] assert len(registry.issues) == 3 @pytest.mark.parametrize("load_registries", [False]) async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: """Test migration from version 1.1.""" - hass_storage[issue_registry.STORAGE_KEY] = { + hass_storage[ir.STORAGE_KEY] = { "version": 1, "minor_version": 1, "data": { @@ -246,7 +242,7 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) }, } - await issue_registry.async_load(hass) + await ir.async_load(hass) - registry: issue_registry.IssueRegistry = hass.data[issue_registry.DATA_REGISTRY] + registry: ir.IssueRegistry = hass.data[ir.DATA_REGISTRY] assert len(registry.issues) == 2 From 0e4c32efe2b761ff376706ec0d56ba9b4573e7bc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:56:18 +0100 Subject: [PATCH 0132/1058] Adjust registry access in conversation (#88879) --- .../components/conversation/default_agent.py | 16 +-- .../conversation/test_default_agent.py | 46 ++++---- tests/components/conversation/test_init.py | 100 +++++++++++------- 3 files changed, 94 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 78002b42f696..49569f66ac0d 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -18,9 +18,9 @@ import yaml from homeassistant import core, setup from homeassistant.helpers import ( - area_registry, - device_registry, - entity_registry, + area_registry as ar, + device_registry as dr, + entity_registry as er, intent, template, translation, @@ -95,12 +95,12 @@ class DefaultAgent(AbstractConversationAgent): self._config_intents = config_intents self.hass.bus.async_listen( - area_registry.EVENT_AREA_REGISTRY_UPDATED, + ar.EVENT_AREA_REGISTRY_UPDATED, self._async_handle_area_registry_changed, run_immediately=True, ) self.hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + er.EVENT_ENTITY_REGISTRY_UPDATED, self._async_handle_entity_registry_changed, run_immediately=True, ) @@ -471,8 +471,8 @@ class DefaultAgent(AbstractConversationAgent): states = [ state for state in self.hass.states.async_all() if is_entity_exposed(state) ] - entities = entity_registry.async_get(self.hass) - devices = device_registry.async_get(self.hass) + entities = er.async_get(self.hass) + devices = dr.async_get(self.hass) # Gather exposed entity names entity_names = [] @@ -512,7 +512,7 @@ class DefaultAgent(AbstractConversationAgent): entity_names.append((state.name, state.name, context)) # Gather areas from exposed entities - areas = area_registry.async_get(self.hass) + areas = ar.async_get(self.hass) area_names = [] for area_id in area_ids_with_entities: area = areas.async_get_area(area_id) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 726ee4dc6e35..338d840c4a79 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -7,10 +7,10 @@ from homeassistant.components import conversation from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant from homeassistant.helpers import ( - area_registry, - device_registry, + area_registry as ar, + device_registry as dr, entity, - entity_registry, + entity_registry as er, intent, ) from homeassistant.setup import async_setup_component @@ -29,19 +29,18 @@ async def init_components(hass): @pytest.mark.parametrize( "er_kwargs", [ - {"hidden_by": entity_registry.RegistryEntryHider.USER}, - {"hidden_by": entity_registry.RegistryEntryHider.INTEGRATION}, + {"hidden_by": er.RegistryEntryHider.USER}, + {"hidden_by": er.RegistryEntryHider.INTEGRATION}, {"entity_category": entity.EntityCategory.CONFIG}, {"entity_category": entity.EntityCategory.DIAGNOSTIC}, ], ) async def test_hidden_entities_skipped( - hass: HomeAssistant, init_components, er_kwargs + hass: HomeAssistant, init_components, er_kwargs, entity_registry: er.EntityRegistry ) -> None: """Test we skip hidden entities.""" - er = entity_registry.async_get(hass) - er.async_get_or_create( + entity_registry.async_get_or_create( "light", "demo", "1234", suggested_object_id="Test light", **er_kwargs ) hass.states.async_set("light.test_light", "off") @@ -71,27 +70,34 @@ async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: assert result.response.error_code == intent.IntentResponseErrorCode.NO_INTENT_MATCH -async def test_exposed_areas(hass: HomeAssistant, init_components) -> None: +async def test_exposed_areas( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test that only expose areas with an exposed entity/device.""" - areas = area_registry.async_get(hass) - area_kitchen = areas.async_get_or_create("kitchen") - area_bedroom = areas.async_get_or_create("bedroom") + area_kitchen = area_registry.async_get_or_create("kitchen") + area_bedroom = area_registry.async_get_or_create("bedroom") - devices = device_registry.async_get(hass) - kitchen_device = devices.async_get_or_create( + kitchen_device = device_registry.async_get_or_create( config_entry_id="1234", connections=set(), identifiers={("demo", "id-1234")} ) - devices.async_update_device(kitchen_device.id, area_id=area_kitchen.id) + device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) - entities = entity_registry.async_get(hass) - kitchen_light = entities.async_get_or_create("light", "demo", "1234") - entities.async_update_entity(kitchen_light.entity_id, device_id=kitchen_device.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 + ) hass.states.async_set( kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} ) - bedroom_light = entities.async_get_or_create("light", "demo", "5678") - entities.async_update_entity(bedroom_light.entity_id, area_id=area_bedroom.id) + bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") + entity_registry.async_update_entity( + bedroom_light.entity_id, area_id=area_bedroom.id + ) hass.states.async_set( bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index f0f5698705e8..55a345bd605c 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -12,9 +12,9 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import ( - area_registry, - device_registry, - entity_registry, + area_registry as ar, + device_registry as dr, + entity_registry as er, intent, ) from homeassistant.setup import async_setup_component @@ -53,12 +53,14 @@ async def test_http_processing_intent( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, agent_id, + entity_registry: er.EntityRegistry, ) -> None: """Test processing intent via HTTP API.""" # Add an alias - entities = entity_registry.async_get(hass) - entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - entities.async_update_entity("light.kitchen", aliases={"my cool light"}) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) hass.states.async_set("light.kitchen", "off") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") @@ -101,12 +103,14 @@ async def test_http_processing_intent_target_ha_agent( hass_client: ClientSessionGenerator, hass_admin_user: MockUser, mock_agent, + entity_registry: er.EntityRegistry, ) -> None: """Test processing intent can be processed via HTTP API with picking agent.""" # Add an alias - entities = entity_registry.async_get(hass) - entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - entities.async_update_entity("light.kitchen", aliases={"my cool light"}) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) hass.states.async_set("light.kitchen", "off") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") @@ -148,15 +152,17 @@ async def test_http_processing_intent_entity_added( init_components, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, + entity_registry: er.EntityRegistry, ) -> None: """Test processing intent via HTTP API with entities added later. We want to ensure that adding an entity later busts the cache so that the new entity is available as well as any aliases. """ - er = entity_registry.async_get(hass) - er.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - er.async_update_entity("light.kitchen", aliases={"my cool light"}) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity("light.kitchen", aliases={"my cool light"}) hass.states.async_set("light.kitchen", "off") calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") @@ -192,7 +198,9 @@ async def test_http_processing_intent_entity_added( } # Add an alias - er.async_get_or_create("light", "demo", "5678", suggested_object_id="late") + entity_registry.async_get_or_create( + "light", "demo", "5678", suggested_object_id="late" + ) hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"}) client = await hass_client() @@ -226,7 +234,7 @@ async def test_http_processing_intent_entity_added( } # Now add an alias - er.async_update_entity("light.late", aliases={"late added light"}) + entity_registry.async_update_entity("light.late", aliases={"late added light"}) client = await hass_client() resp = await client.post( @@ -259,7 +267,7 @@ async def test_http_processing_intent_entity_added( } # Now delete the entity - er.async_remove("light.late") + entity_registry.async_remove("light.late") client = await hass_client() resp = await client.post( @@ -786,23 +794,28 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non assert result.response.speech["plain"]["speech"] == "Opened" -async def test_turn_on_area(hass: HomeAssistant, init_components) -> None: +async def test_turn_on_area( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test turning on an area.""" - er = entity_registry.async_get(hass) - dr = device_registry.async_get(hass) - ar = area_registry.async_get(hass) entry = MockConfigEntry(domain="test") - device = dr.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - kitchen_area = ar.async_create("kitchen") - dr.async_update_device(device.id, area_id=kitchen_area.id) + kitchen_area = area_registry.async_create("kitchen") + device_registry.async_update_device(device.id, area_id=kitchen_area.id) - er.async_get_or_create("light", "demo", "1234", suggested_object_id="stove") - er.async_update_entity( + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="stove" + ) + entity_registry.async_update_entity( "light.stove", aliases={"my stove light"}, area_id=kitchen_area.id ) hass.states.async_set("light.stove", "off") @@ -822,9 +835,9 @@ async def test_turn_on_area(hass: HomeAssistant, init_components) -> None: assert call.service == "turn_on" assert call.data == {"entity_id": ["light.stove"]} - basement_area = ar.async_create("basement") - dr.async_update_device(device.id, area_id=basement_area.id) - er.async_update_entity("light.stove", area_id=basement_area.id) + basement_area = area_registry.async_create("basement") + device_registry.async_update_device(device.id, area_id=basement_area.id) + entity_registry.async_update_entity("light.stove", area_id=basement_area.id) calls.clear() # Test that the area is updated @@ -852,33 +865,40 @@ async def test_turn_on_area(hass: HomeAssistant, init_components) -> None: assert call.data == {"entity_id": ["light.stove"]} -async def test_light_area_same_name(hass: HomeAssistant, init_components) -> None: +async def test_light_area_same_name( + hass: HomeAssistant, + init_components, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test turning on a light with the same name as an area.""" - entities = entity_registry.async_get(hass) - devices = device_registry.async_get(hass) - areas = area_registry.async_get(hass) entry = MockConfigEntry(domain="test") - device = devices.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - kitchen_area = areas.async_create("kitchen") - devices.async_update_device(device.id, area_id=kitchen_area.id) + kitchen_area = area_registry.async_create("kitchen") + device_registry.async_update_device(device.id, area_id=kitchen_area.id) - kitchen_light = entities.async_get_or_create( + kitchen_light = entity_registry.async_get_or_create( "light", "demo", "1234", original_name="kitchen light" ) - entities.async_update_entity(kitchen_light.entity_id, area_id=kitchen_area.id) + entity_registry.async_update_entity( + kitchen_light.entity_id, area_id=kitchen_area.id + ) hass.states.async_set( kitchen_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} ) - ceiling_light = entities.async_get_or_create( + ceiling_light = entity_registry.async_get_or_create( "light", "demo", "5678", original_name="ceiling light" ) - entities.async_update_entity(ceiling_light.entity_id, area_id=kitchen_area.id) + entity_registry.async_update_entity( + ceiling_light.entity_id, area_id=kitchen_area.id + ) hass.states.async_set( ceiling_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "ceiling light"} ) From 1bed5c777587121308f9401c5aa84013119f425b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:56:46 +0100 Subject: [PATCH 0133/1058] Adjust registry access in tests root (#88880) --- tests/common.py | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/common.py b/tests/common.py index 66875eb6e9f3..c25ad0cca61a 100644 --- a/tests/common.py +++ b/tests/common.py @@ -50,13 +50,13 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import ( - area_registry, - device_registry, + area_registry as ar, + device_registry as dr, entity, entity_platform, - entity_registry, + entity_registry as er, intent, - issue_registry, + issue_registry as ir, recorder as recorder_helper, restore_state, storage, @@ -251,10 +251,10 @@ async def async_test_home_assistant(event_loop, load_registries=True): if load_registries: with patch("homeassistant.helpers.storage.Store.async_load", return_value=None): await asyncio.gather( - area_registry.async_load(hass), - device_registry.async_load(hass), - entity_registry.async_load(hass), - issue_registry.async_load(hass), + ar.async_load(hass), + dr.async_load(hass), + er.async_load(hass), + ir.async_load(hass), ) hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None @@ -481,8 +481,8 @@ def mock_component(hass: HomeAssistant, component: str) -> None: def mock_registry( hass: HomeAssistant, - mock_entries: dict[str, entity_registry.RegistryEntry] | None = None, -) -> entity_registry.EntityRegistry: + mock_entries: dict[str, er.RegistryEntry] | None = None, +) -> er.EntityRegistry: """Mock the Entity Registry. This should only be used if you need to mock/re-stage a clean mocked @@ -494,20 +494,20 @@ def mock_registry( If you just need to access the existing registry, use the `entity_registry` fixture instead. """ - registry = entity_registry.EntityRegistry(hass) + registry = er.EntityRegistry(hass) if mock_entries is None: mock_entries = {} - registry.entities = entity_registry.EntityRegistryItems() + registry.entities = er.EntityRegistryItems() for key, entry in mock_entries.items(): registry.entities[key] = entry - hass.data[entity_registry.DATA_REGISTRY] = registry + hass.data[er.DATA_REGISTRY] = registry return registry def mock_area_registry( - hass: HomeAssistant, mock_entries: dict[str, area_registry.AreaEntry] | None = None -) -> area_registry.AreaRegistry: + hass: HomeAssistant, mock_entries: dict[str, ar.AreaEntry] | None = None +) -> ar.AreaRegistry: """Mock the Area Registry. This should only be used if you need to mock/re-stage a clean mocked @@ -519,17 +519,17 @@ def mock_area_registry( If you just need to access the existing registry, use the `area_registry` fixture instead. """ - registry = area_registry.AreaRegistry(hass) + registry = ar.AreaRegistry(hass) registry.areas = mock_entries or OrderedDict() - hass.data[area_registry.DATA_REGISTRY] = registry + hass.data[ar.DATA_REGISTRY] = registry return registry def mock_device_registry( hass: HomeAssistant, - mock_entries: dict[str, device_registry.DeviceEntry] | None = None, -) -> device_registry.DeviceRegistry: + mock_entries: dict[str, dr.DeviceEntry] | None = None, +) -> dr.DeviceRegistry: """Mock the Device Registry. This should only be used if you need to mock/re-stage a clean mocked @@ -541,15 +541,15 @@ def mock_device_registry( If you just need to access the existing registry, use the `device_registry` fixture instead. """ - registry = device_registry.DeviceRegistry(hass) - registry.devices = device_registry.DeviceRegistryItems() + registry = dr.DeviceRegistry(hass) + registry.devices = dr.DeviceRegistryItems() if mock_entries is None: mock_entries = {} for key, entry in mock_entries.items(): registry.devices[key] = entry - registry.deleted_devices = device_registry.DeviceRegistryItems() + registry.deleted_devices = dr.DeviceRegistryItems() - hass.data[device_registry.DATA_REGISTRY] = registry + hass.data[dr.DATA_REGISTRY] = registry return registry From 54f709f70401ddf526846407c9e5e79d91af6e67 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:58:19 +0100 Subject: [PATCH 0134/1058] Adjust registry access in intent (#88881) --- homeassistant/components/intent/__init__.py | 6 ++-- homeassistant/components/light/intent.py | 6 ++-- tests/components/intent/test_init.py | 40 +++++++++++---------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index a52f4897d234..6bc3d88287fc 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, State from homeassistant.helpers import ( - area_registry, + area_registry as ar, config_validation as cv, integration_platform, intent, @@ -109,9 +109,9 @@ class GetStateIntentHandler(intent.IntentHandler): # Look up area first to fail early area_name = slots.get("area", {}).get("value") - area: area_registry.AreaEntry | None = None + area: ar.AreaEntry | None = None if area_name is not None: - areas = area_registry.async_get(hass) + areas = ar.async_get(hass) area = areas.async_get_area(area_name) or areas.async_get_area_by_name( area_name ) diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index 7b75821ab432..605434af9162 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry, config_validation as cv, intent +from homeassistant.helpers import area_registry as ar, config_validation as cv, intent import homeassistant.util.color as color_util from . import ( @@ -56,9 +56,9 @@ class SetIntentHandler(intent.IntentHandler): # Look up area first to fail early area_name = slots.get("area", {}).get("value") - area: area_registry.AreaEntry | None = None + area: ar.AreaEntry | None = None if area_name is not None: - areas = area_registry.async_get(hass) + areas = ar.async_get(hass) area = areas.async_get_area(area_name) or areas.async_get_area_by_name( area_name ) diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index d615d12603ce..fa8eb9cad61e 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -10,7 +10,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import area_registry, entity_registry, intent +from homeassistant.helpers import area_registry as ar, entity_registry as er, intent from homeassistant.setup import async_setup_component from tests.common import MockUser, async_mock_service @@ -186,7 +186,11 @@ async def test_turn_on_multiple_intent(hass: HomeAssistant) -> None: assert call.data == {"entity_id": ["light.test_lights_2"]} -async def test_get_state_intent(hass: HomeAssistant) -> None: +async def test_get_state_intent( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test HassGetState intent. This tests name, area, domain, device class, and state constraints. @@ -194,33 +198,31 @@ async def test_get_state_intent(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "intent", {}) - areas = area_registry.async_get(hass) - bedroom = areas.async_get_or_create("bedroom") - kitchen = areas.async_get_or_create("kitchen") - office = areas.async_get_or_create("office") + bedroom = area_registry.async_get_or_create("bedroom") + kitchen = area_registry.async_get_or_create("kitchen") + office = area_registry.async_get_or_create("office") # 1 light in bedroom (off) # 1 light in kitchen (on) # 1 sensor in kitchen (50) # 2 binary sensors in the office (problem, moisture, on) - entities = entity_registry.async_get(hass) - bedroom_light = entities.async_get_or_create("light", "demo", "1") - entities.async_update_entity(bedroom_light.entity_id, area_id=bedroom.id) + bedroom_light = entity_registry.async_get_or_create("light", "demo", "1") + entity_registry.async_update_entity(bedroom_light.entity_id, area_id=bedroom.id) - kitchen_sensor = entities.async_get_or_create("sensor", "demo", "2") - entities.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) + kitchen_sensor = entity_registry.async_get_or_create("sensor", "demo", "2") + entity_registry.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) - kitchen_light = entities.async_get_or_create("light", "demo", "3") - entities.async_update_entity(kitchen_light.entity_id, area_id=kitchen.id) + kitchen_light = entity_registry.async_get_or_create("light", "demo", "3") + entity_registry.async_update_entity(kitchen_light.entity_id, area_id=kitchen.id) - kitchen_sensor = entities.async_get_or_create("sensor", "demo", "4") - entities.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) + kitchen_sensor = entity_registry.async_get_or_create("sensor", "demo", "4") + entity_registry.async_update_entity(kitchen_sensor.entity_id, area_id=kitchen.id) - problem_sensor = entities.async_get_or_create("binary_sensor", "demo", "5") - entities.async_update_entity(problem_sensor.entity_id, area_id=office.id) + problem_sensor = entity_registry.async_get_or_create("binary_sensor", "demo", "5") + entity_registry.async_update_entity(problem_sensor.entity_id, area_id=office.id) - moisture_sensor = entities.async_get_or_create("binary_sensor", "demo", "6") - entities.async_update_entity(moisture_sensor.entity_id, area_id=office.id) + moisture_sensor = entity_registry.async_get_or_create("binary_sensor", "demo", "6") + entity_registry.async_update_entity(moisture_sensor.entity_id, area_id=office.id) hass.states.async_set( bedroom_light.entity_id, "off", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} From ee781e4f494282fdfc0c6329a89f08f56bf1cbde Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:58:47 +0100 Subject: [PATCH 0135/1058] Adjust registry access in scripts (#88884) --- homeassistant/scripts/check_config.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 85d0e77a4e38..92f5b442d9ed 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -15,7 +15,11 @@ from homeassistant import core from homeassistant.config import get_default_config_dir from homeassistant.config_entries import ConfigEntries from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import area_registry, device_registry, entity_registry +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.check_config import async_check_ha_config_file from homeassistant.util.yaml import Secrets import homeassistant.util.yaml.loader as yaml_loader @@ -230,9 +234,9 @@ async def async_check_config(config_dir): hass = core.HomeAssistant() hass.config.config_dir = config_dir hass.config_entries = ConfigEntries(hass, {}) - await area_registry.async_load(hass) - await device_registry.async_load(hass) - await entity_registry.async_load(hass) + await ar.async_load(hass) + await dr.async_load(hass) + await er.async_load(hass) components = await async_check_ha_config_file(hass) await hass.async_stop(force=True) return components From 246f9784c81b849985bfd396d955a0075d7d7a2a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:59:26 +0100 Subject: [PATCH 0136/1058] Adjust registry access in Google Assistant (#88883) --- .../components/google_assistant/helpers.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 196fa580ea82..e194242df91a 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -22,7 +22,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import Context, HomeAssistant, State, callback -from homeassistant.helpers import area_registry, device_registry, entity_registry, start +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + start, +) from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.storage import Store @@ -52,15 +57,11 @@ LOCAL_SDK_MIN_VERSION = AwesomeVersion("2.1.5") @callback def _get_registry_entries( hass: HomeAssistant, entity_id: str -) -> tuple[ - entity_registry.RegistryEntry | None, - device_registry.DeviceEntry | None, - area_registry.AreaEntry | None, -]: +) -> tuple[er.RegistryEntry | None, dr.DeviceEntry | None, ar.AreaEntry | None,]: """Get registry entries.""" - ent_reg = entity_registry.async_get(hass) - dev_reg = device_registry.async_get(hass) - area_reg = area_registry.async_get(hass) + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + area_reg = ar.async_get(hass) if (entity_entry := ent_reg.async_get(entity_id)) and entity_entry.device_id: device_entry = dev_reg.devices.get(entity_entry.device_id) From c724e7c29f97699c6a64d28e8e19762858671101 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 03:59:44 +0100 Subject: [PATCH 0137/1058] Adjust registry access in openai_conversation (#88882) --- .../openai_conversation/__init__.py | 4 +-- .../openai_conversation/test_init.py | 36 ++++++++++--------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 41ff6bcf9cd5..355b7764b087 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry, intent, template +from homeassistant.helpers import area_registry as ar, intent, template from homeassistant.util import ulid from .const import ( @@ -150,7 +150,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(area_registry.async_get(self.hass).areas.values()), + "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index defadcc0d9a2..3b78a90f40ec 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -5,20 +5,22 @@ from openai import error from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import area_registry, device_registry, intent +from homeassistant.helpers import area_registry as ar, device_registry as dr, intent from tests.common import MockConfigEntry -async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: +async def test_default_prompt( + hass: HomeAssistant, + mock_init_component, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, +) -> None: """Test that the default prompt works.""" - device_reg = device_registry.async_get(hass) - area_reg = area_registry.async_get(hass) - for i in range(3): - area_reg.async_create(f"{i}Empty Area") + area_registry.async_create(f"{i}Empty Area") - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "1234")}, name="Test Device", @@ -27,16 +29,16 @@ async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: suggested_area="Test Area", ) for i in range(3): - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", f"{i}abcd")}, name="Test Service", manufacturer="Test Manufacturer", model="Test Model", suggested_area="Test Area", - entry_type=device_registry.DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "5678")}, name="Test Device 2", @@ -44,7 +46,7 @@ async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: model="Device 2", suggested_area="Test Area 2", ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "9876")}, name="Test Device 3", @@ -52,13 +54,13 @@ async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: model="Test Model 3A", suggested_area="Test Area 2", ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "qwer")}, name="Test Device 4", suggested_area="Test Area 2", ) - device = device_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "9876-disabled")}, name="Test Device 3", @@ -66,17 +68,17 @@ async def test_default_prompt(hass: HomeAssistant, mock_init_component) -> None: model="Test Model 3A", suggested_area="Test Area 2", ) - device_reg.async_update_device( - device.id, disabled_by=device_registry.DeviceEntryDisabler.USER + device_registry.async_update_device( + device.id, disabled_by=dr.DeviceEntryDisabler.USER ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "9876-no-name")}, manufacturer="Test Manufacturer NoName", model="Test Model NoName", suggested_area="Test Area 2", ) - device_reg.async_get_or_create( + device_registry.async_get_or_create( config_entry_id="1234", connections={("test", "9876-integer-values")}, name=1, From 3e8716b37edcc2c161edab2c4835314d53907798 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 04:01:36 +0100 Subject: [PATCH 0138/1058] Adjust AddEntitiesCallback import (part 2) (#88873) --- homeassistant/components/eight_sleep/sensor.py | 9 ++++++--- homeassistant/components/ezviz/camera.py | 14 ++++++-------- homeassistant/components/hue/scene.py | 9 ++++++--- homeassistant/components/keymitt_ble/switch.py | 12 +++++++----- homeassistant/components/unifiprotect/select.py | 16 +++++++--------- homeassistant/components/zha/lock.py | 10 +++++++--- 6 files changed, 39 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index 58648123dcfd..e546318a4ddd 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -15,7 +15,10 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_platform as ep +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import EightSleepBaseEntity, EightSleepConfigEntryData @@ -68,7 +71,7 @@ SERVICE_EIGHT_SCHEMA = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: ep.AddEntitiesCallback + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the eight sleep sensors.""" config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] @@ -95,7 +98,7 @@ async def async_setup_entry( async_add_entities(all_sensors) - platform = ep.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_HEAT_SET, SERVICE_EIGHT_SCHEMA, diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 65b5df100dd1..7901061c0215 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -17,10 +17,10 @@ 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, - entity_platform, +from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, ) from .const import ( @@ -53,9 +53,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up EZVIZ cameras based on a config entry.""" @@ -132,7 +130,7 @@ async def async_setup_entry( async_add_entities(camera_entities) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_PTZ, diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index 1020879ce816..2c6c16797795 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -13,9 +13,12 @@ import voluptuous as vol from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from .bridge import HueBridge from .const import DOMAIN @@ -31,7 +34,7 @@ ATTR_BRIGHTNESS = "brightness" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up scene platform from Hue group scenes.""" bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] @@ -62,7 +65,7 @@ async def async_setup_entry( ) # add platform service to turn_on/activate scene with advanced options - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_ACTIVATE_SCENE, { diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py index 099ad1f228af..3e5883ae5d0c 100644 --- a/homeassistant/components/keymitt_ble/switch.py +++ b/homeassistant/components/keymitt_ble/switch.py @@ -8,7 +8,11 @@ import voluptuous as vol from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from .const import DOMAIN from .coordinator import MicroBotDataUpdateCoordinator @@ -23,14 +27,12 @@ CALIBRATE_SCHEMA = { async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up MicroBot based on a config entry.""" coordinator: MicroBotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([MicroBotBinarySwitch(coordinator, entry)]) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( CALIBRATE, CALIBRATE_SCHEMA, diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7bc54aa7afe1..7fe43bee9bb4 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -32,12 +32,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_validation as cv, - entity_platform, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util.dt import utcnow from .const import ATTR_DURATION, ATTR_MESSAGE, DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE @@ -319,9 +319,7 @@ VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up number entities for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] @@ -354,7 +352,7 @@ async def async_setup_entry( ) async_add_entities(entities) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_DOORBELL_MESSAGE, SET_DOORBELL_LCD_MESSAGE_SCHEMA, diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index a2ec5e068cbc..433f662a7854 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -9,8 +9,12 @@ from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockEnti from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.helpers.typing import StateType from .core import discovery @@ -38,7 +42,7 @@ SERVICE_CLEAR_LOCK_USER_CODE = "clear_lock_user_code" async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entities: entity_platform.AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Door Lock from config entry.""" entities_to_create = hass.data[DATA_ZHA][Platform.LOCK] @@ -52,7 +56,7 @@ async def async_setup_entry( ) config_entry.async_on_unload(unsub) - platform = entity_platform.async_get_current_platform() + platform = async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_LOCK_USER_CODE, From 5b496488469fd9d55995da4c53d9469f1139aea4 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Wed, 1 Mar 2023 04:02:52 +0100 Subject: [PATCH 0139/1058] Update Tado services.yaml defaults (#88929) Update services.yaml Deletes default values in 'time_period' and 'requested_overlay' fields in 'set_climate_timer'. --- homeassistant/components/tado/services.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/tado/services.yaml b/homeassistant/components/tado/services.yaml index 8f5858793959..3c5a830698db 100644 --- a/homeassistant/components/tado/services.yaml +++ b/homeassistant/components/tado/services.yaml @@ -21,7 +21,6 @@ set_climate_timer: description: Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay required: false example: "01:30:00" - default: "01:00:00" selector: text: requested_overlay: @@ -29,7 +28,6 @@ set_climate_timer: description: Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting required: false example: "MANUAL" - default: "TADO_DEFAULT" selector: select: options: From 09d01286012febe46a938a92a2c5c89c772c78de Mon Sep 17 00:00:00 2001 From: Chuck Deal <106625807+chuckdeal97@users.noreply.github.com> Date: Tue, 28 Feb 2023 22:12:48 -0500 Subject: [PATCH 0140/1058] Add diagnostics to VeSync (#86350) * Add diagnostics to VeSync * Create unit tests for diagnostics and init * Improved diagnostic test coverage * Peer review fixes * Fixed Peer Review comments * Updated based on Peer Review * Additional diagnostic redactions * Removed account_id from diagnostic output --- .coveragerc | 1 - .../components/vesync/diagnostics.py | 119 ++++++++ tests/components/vesync/common.py | 72 +++++ tests/components/vesync/conftest.py | 104 +++++++ ..._api_call__device_details__single_fan.json | 15 + ...ll__device_details__single_humidifier.json | 27 ++ .../vesync_api_call__devices__no_devices.json | 11 + .../vesync_api_call__devices__single_fan.json | 37 +++ ..._api_call__devices__single_humidifier.json | 37 +++ .../fixtures/vesync_api_call__login.json | 9 + .../vesync/snapshots/test_diagnostics.ambr | 272 ++++++++++++++++++ tests/components/vesync/test_diagnostics.py | 99 +++++++ tests/components/vesync/test_init.py | 103 +++++++ 13 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vesync/diagnostics.py create mode 100644 tests/components/vesync/common.py create mode 100644 tests/components/vesync/conftest.py create mode 100644 tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json create mode 100644 tests/components/vesync/fixtures/vesync_api_call__login.json create mode 100644 tests/components/vesync/snapshots/test_diagnostics.ambr create mode 100644 tests/components/vesync/test_diagnostics.py create mode 100644 tests/components/vesync/test_init.py diff --git a/.coveragerc b/.coveragerc index 8a5b90b5d766..47376833679d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1368,7 +1368,6 @@ omit = homeassistant/components/verisure/sensor.py homeassistant/components/verisure/switch.py homeassistant/components/versasense/* - homeassistant/components/vesync/__init__.py homeassistant/components/vesync/common.py homeassistant/components/vesync/fan.py homeassistant/components/vesync/light.py diff --git a/homeassistant/components/vesync/diagnostics.py b/homeassistant/components/vesync/diagnostics.py new file mode 100644 index 000000000000..8043e93b9e4f --- /dev/null +++ b/homeassistant/components/vesync/diagnostics.py @@ -0,0 +1,119 @@ +"""Diagnostics support for VeSync.""" +from __future__ import annotations + +from typing import Any + +from pyvesync import VeSync + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .common import VeSyncBaseDevice +from .const import DOMAIN, VS_MANAGER + +KEYS_TO_REDACT = {"manager", "uuid", "mac_id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + manager: VeSync = hass.data[DOMAIN][VS_MANAGER] + + data = { + DOMAIN: { + "bulb_count": len(manager.bulbs), + "fan_count": len(manager.fans), + "outlets_count": len(manager.outlets), + "switch_count": len(manager.switches), + "timezone": manager.time_zone, + }, + "devices": { + "bulbs": [_redact_device_values(device) for device in manager.bulbs], + "fans": [_redact_device_values(device) for device in manager.fans], + "outlets": [_redact_device_values(device) for device in manager.outlets], + "switches": [_redact_device_values(device) for device in manager.switches], + }, + } + + return data + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + manager: VeSync = hass.data[DOMAIN][VS_MANAGER] + device_dict = _build_device_dict(manager) + vesync_device_id = next(iden[1] for iden in device.identifiers if iden[0] == DOMAIN) + + # Base device information, without sensitive information. + data = _redact_device_values(device_dict[vesync_device_id]) + + data["home_assistant"] = { + "name": device.name, + "name_by_user": device.name_by_user, + "disabled": device.disabled, + "disabled_by": device.disabled_by, + "entities": [], + } + + # Gather information how this VeSync device is represented in Home Assistant + entity_registry = er.async_get(hass) + hass_entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + + for entity_entry in hass_entities: + state = hass.states.get(entity_entry.entity_id) + state_dict = None + if state: + state_dict = dict(state.as_dict()) + # The context doesn't provide useful information in this case. + state_dict.pop("context", None) + + data["home_assistant"]["entities"].append( + { + "domain": entity_entry.domain, + "entity_id": entity_entry.entity_id, + "entity_category": entity_entry.entity_category, + "device_class": entity_entry.device_class, + "original_device_class": entity_entry.original_device_class, + "name": entity_entry.name, + "original_name": entity_entry.original_name, + "icon": entity_entry.icon, + "original_icon": entity_entry.original_icon, + "unit_of_measurement": entity_entry.unit_of_measurement, + "state": state_dict, + "disabled": entity_entry.disabled, + "disabled_by": entity_entry.disabled_by, + } + ) + + return data + + +def _build_device_dict(manager: VeSync) -> dict: + """Build a dictionary of ALL VeSync devices.""" + device_dict = {x.cid: x for x in manager.switches} + device_dict.update({x.cid: x for x in manager.fans}) + device_dict.update({x.cid: x for x in manager.outlets}) + device_dict.update({x.cid: x for x in manager.bulbs}) + return device_dict + + +def _redact_device_values(device: VeSyncBaseDevice) -> dict: + """Rebuild and redact values of a VeSync device.""" + data = {} + for key, item in device.__dict__.items(): + if key not in KEYS_TO_REDACT: + data[key] = item + else: + data[key] = REDACTED + + return data diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py new file mode 100644 index 000000000000..39cd66a5936b --- /dev/null +++ b/tests/components/vesync/common.py @@ -0,0 +1,72 @@ +"""Common methods used across tests for VeSync.""" +import json + +from tests.common import load_fixture + + +def call_api_side_effect__no_devices(*args, **kwargs): + """Build a side_effects method for the Helpers.call_api method.""" + if args[0] == "/cloud/v1/user/login" and args[1] == "post": + return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 + elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + return ( + json.loads( + load_fixture("vesync_api_call__devices__no_devices.json", "vesync") + ), + 200, + ) + else: + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") + + +def call_api_side_effect__single_humidifier(*args, **kwargs): + """Build a side_effects method for the Helpers.call_api method.""" + if args[0] == "/cloud/v1/user/login" and args[1] == "post": + return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 + elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + return ( + json.loads( + load_fixture( + "vesync_api_call__devices__single_humidifier.json", "vesync" + ) + ), + 200, + ) + elif args[0] == "/cloud/v2/deviceManaged/bypassV2" and kwargs["method"] == "post": + return ( + json.loads( + load_fixture( + "vesync_api_call__device_details__single_humidifier.json", "vesync" + ) + ), + 200, + ) + else: + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") + + +def call_api_side_effect__single_fan(*args, **kwargs): + """Build a side_effects method for the Helpers.call_api method.""" + if args[0] == "/cloud/v1/user/login" and args[1] == "post": + return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 + elif args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": + return ( + json.loads( + load_fixture("vesync_api_call__devices__single_fan.json", "vesync") + ), + 200, + ) + elif ( + args[0] == "/131airPurifier/v1/device/deviceDetail" + and kwargs["method"] == "post" + ): + return ( + json.loads( + load_fixture( + "vesync_api_call__device_details__single_fan.json", "vesync" + ) + ), + 200, + ) + else: + raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py new file mode 100644 index 000000000000..8815a4b9748f --- /dev/null +++ b/tests/components/vesync/conftest.py @@ -0,0 +1,104 @@ +"""Configuration for VeSync tests.""" +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from pyvesync import VeSync +from pyvesync.vesyncbulb import VeSyncBulb +from pyvesync.vesyncfan import VeSyncAirBypass +from pyvesync.vesyncoutlet import VeSyncOutlet +from pyvesync.vesyncswitch import VeSyncSwitch + +from homeassistant.components.vesync import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant, config) -> ConfigEntry: + """Create a mock VeSync config entry.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config") +def config_fixture() -> ConfigType: + """Create hass config fixture.""" + return {DOMAIN: {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}} + + +@pytest.fixture(name="manager") +def manager_fixture() -> VeSync: + """Create a mock VeSync manager fixture.""" + + outlets = [] + switches = [] + fans = [] + bulbs = [] + + mock_vesync = Mock(VeSync) + mock_vesync.login = Mock(return_value=True) + mock_vesync.update = Mock() + mock_vesync.outlets = outlets + mock_vesync.switches = switches + mock_vesync.fans = fans + mock_vesync.bulbs = bulbs + mock_vesync._dev_list = { + "fans": fans, + "outlets": outlets, + "switches": switches, + "bulbs": bulbs, + } + mock_vesync.account_id = "account_id" + mock_vesync.time_zone = "America/New_York" + mock = Mock(return_value=mock_vesync) + + with patch("homeassistant.components.vesync.VeSync", new=mock): + yield mock_vesync + + +@pytest.fixture(name="fan") +def fan_fixture(): + """Create a mock VeSync fan fixture.""" + mock_fixture = Mock(VeSyncAirBypass) + return mock_fixture + + +@pytest.fixture(name="bulb") +def bulb_fixture(): + """Create a mock VeSync bulb fixture.""" + mock_fixture = Mock(VeSyncBulb) + return mock_fixture + + +@pytest.fixture(name="switch") +def switch_fixture(): + """Create a mock VeSync switch fixture.""" + mock_fixture = Mock(VeSyncSwitch) + mock_fixture.is_dimmable = Mock(return_value=False) + return mock_fixture + + +@pytest.fixture(name="dimmable_switch") +def dimmable_switch_fixture(): + """Create a mock VeSync switch fixture.""" + mock_fixture = Mock(VeSyncSwitch) + mock_fixture.is_dimmable = Mock(return_value=True) + return mock_fixture + + +@pytest.fixture(name="outlet") +def outlet_fixture(): + """Create a mock VeSync outlet fixture.""" + mock_fixture = Mock(VeSyncOutlet) + return mock_fixture diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json new file mode 100644 index 000000000000..35b5a02fb3db --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json @@ -0,0 +1,15 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "enabled": false, + "mode": "humidity" + } + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json new file mode 100644 index 000000000000..f9e4b0e18f1e --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json @@ -0,0 +1,27 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "module": null, + "stacktrace": null, + "result": { + "traceId": "0000000000", + "code": 0, + "result": { + "enabled": false, + "mist_virtual_level": 9, + "mist_level": 3, + "mode": "humidity", + "water_lacks": false, + "water_tank_lifted": false, + "humidity": 35, + "humidity_high": false, + "display": false, + "warm_enabled": false, + "warm_level": 0, + "automatic_stop_reach_target": true, + "configuration": { "auto_target_humidity": 60, "display": true }, + "extension": { "schedule_count": 0, "timer_remain": 0 } + } + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json b/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json new file mode 100644 index 000000000000..f1eaa523101b --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json @@ -0,0 +1,11 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "total": 1, + "pageSize": 100, + "pageNo": 1, + "list": [] + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json new file mode 100644 index 000000000000..2951ab63f030 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "total": 1, + "pageSize": 100, + "pageNo": 1, + "list": [ + { + "deviceRegion": "US", + "isOwner": true, + "authKey": null, + "deviceName": "Fan", + "deviceImg": "", + "cid": "abcdefghabcdefghabcdefghabcdefgh", + "deviceStatus": "off", + "connectionStatus": "online", + "connectionType": "WiFi+BTOnboarding+BTNotify", + "deviceType": "LV-PUR131S", + "type": "wifi-air", + "uuid": "00000000-1111-2222-3333-444444444444", + "configModule": "WFON_AHM_LV-PUR131S_US", + "macID": "00:00:00:00:00:00", + "mode": null, + "speed": null, + "currentFirmVersion": null, + "subDeviceNo": null, + "subDeviceType": null, + "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", + "subDeviceList": null, + "extension": null, + "deviceProp": null + } + ] + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json new file mode 100644 index 000000000000..0f0433944024 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json @@ -0,0 +1,37 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "total": 1, + "pageSize": 100, + "pageNo": 1, + "list": [ + { + "deviceRegion": "US", + "isOwner": true, + "authKey": null, + "deviceName": "Humidifier", + "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", + "cid": "abcdefghabcdefghabcdefghabcdefgh", + "deviceStatus": "off", + "connectionStatus": "online", + "connectionType": "WiFi+BTOnboarding+BTNotify", + "deviceType": "LUH-A602S-WUS", + "type": "wifi-air", + "uuid": "00000000-1111-2222-3333-444444444444", + "configModule": "WFON_AHM_LUH-A602S-WUS_US", + "macID": "00:00:00:00:00:00", + "mode": null, + "speed": null, + "currentFirmVersion": null, + "subDeviceNo": null, + "subDeviceType": null, + "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", + "subDeviceList": null, + "extension": null, + "deviceProp": null + } + ] + } +} diff --git a/tests/components/vesync/fixtures/vesync_api_call__login.json b/tests/components/vesync/fixtures/vesync_api_call__login.json new file mode 100644 index 000000000000..4a956f673419 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync_api_call__login.json @@ -0,0 +1,9 @@ +{ + "traceId": "0000000000", + "code": 0, + "msg": "request success", + "result": { + "accountID": "9999999", + "token": "TOKEN" + } +} diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..33378d7ccde3 --- /dev/null +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -0,0 +1,272 @@ +# serializer version: 1 +# name: test_async_get_config_entry_diagnostics__no_devices + dict({ + 'devices': dict({ + 'bulbs': list([ + ]), + 'fans': list([ + ]), + 'outlets': list([ + ]), + 'switches': list([ + ]), + }), + 'vesync': dict({ + 'bulb_count': 0, + 'fan_count': 0, + 'outlets_count': 0, + 'switch_count': 0, + 'timezone': 'US/Pacific', + }), + }) +# --- +# name: test_async_get_config_entry_diagnostics__single_humidifier + dict({ + 'devices': dict({ + 'bulbs': list([ + ]), + 'fans': list([ + dict({ + '_api_modes': list([ + 'getHumidifierStatus', + 'setAutomaticStop', + 'setSwitch', + 'setNightLightBrightness', + 'setVirtualLevel', + 'setTargetHumidity', + 'setHumidityMode', + 'setDisplay', + 'setLevel', + ]), + 'cid': 'abcdefghabcdefghabcdefghabcdefgh', + 'config': dict({ + 'auto_target_humidity': 60, + 'automatic_stop': True, + 'display': True, + }), + 'config_dict': dict({ + 'features': list([ + 'warm_mist', + 'nightlight', + ]), + 'mist_levels': list([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]), + 'mist_modes': list([ + 'humidity', + 'sleep', + 'manual', + ]), + 'models': list([ + 'LUH-A602S-WUSR', + 'LUH-A602S-WUS', + 'LUH-A602S-WEUR', + 'LUH-A602S-WEU', + 'LUH-A602S-WJP', + ]), + 'module': 'VeSyncHumid200300S', + 'warm_mist_levels': list([ + 0, + 1, + 2, + 3, + ]), + }), + 'config_module': 'WFON_AHM_LUH-A602S-WUS_US', + 'connection_status': 'online', + 'connection_type': 'WiFi+BTOnboarding+BTNotify', + 'current_firm_version': None, + 'details': dict({ + 'automatic_stop_reach_target': True, + 'display': False, + 'humidity': 35, + 'humidity_high': False, + 'mist_level': 3, + 'mist_virtual_level': 9, + 'mode': 'humidity', + 'night_light_brightness': 0, + 'warm_mist_enabled': False, + 'warm_mist_level': 0, + 'water_lacks': False, + 'water_tank_lifted': False, + }), + 'device_image': 'https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png', + 'device_name': 'Humidifier', + 'device_status': 'off', + 'device_type': 'LUH-A602S-WUS', + 'enabled': False, + 'extension': None, + 'features': list([ + 'warm_mist', + 'nightlight', + ]), + 'mac_id': '**REDACTED**', + 'manager': '**REDACTED**', + 'mist_levels': list([ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]), + 'mist_modes': list([ + 'humidity', + 'sleep', + 'manual', + ]), + 'mode': None, + 'night_light': True, + 'speed': None, + 'sub_device_no': None, + 'type': 'wifi-air', + 'uuid': '**REDACTED**', + 'warm_mist_feature': True, + 'warm_mist_levels': list([ + 0, + 1, + 2, + 3, + ]), + }), + ]), + 'outlets': list([ + ]), + 'switches': list([ + ]), + }), + 'vesync': dict({ + 'bulb_count': 0, + 'fan_count': 1, + 'outlets_count': 0, + 'switch_count': 0, + 'timezone': 'US/Pacific', + }), + }) +# --- +# name: test_async_get_device_diagnostics__single_fan + dict({ + 'cid': 'abcdefghabcdefghabcdefghabcdefgh', + 'config': dict({ + }), + 'config_module': 'WFON_AHM_LV-PUR131S_US', + 'connection_status': 'unknown', + 'connection_type': 'WiFi+BTOnboarding+BTNotify', + 'current_firm_version': None, + 'details': dict({ + 'active_time': 0, + 'air_quality': 'unknown', + 'filter_life': dict({ + }), + 'level': 0, + 'screen_status': 'unknown', + }), + 'device_image': '', + 'device_name': 'Fan', + 'device_status': 'unknown', + 'device_type': 'LV-PUR131S', + 'extension': None, + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.fan', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan', + 'preset_modes': list([ + 'auto', + 'sleep', + ]), + 'supported_features': 1, + }), + 'entity_id': 'fan.fan', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.fan_filter_life', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan Filter Life', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Filter Life', + 'state_class': 'measurement', + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.fan_filter_life', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': '%', + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fan_air_quality', + 'icon': None, + 'name': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan Air Quality', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Fan Air Quality', + }), + 'entity_id': 'sensor.fan_air_quality', + 'last_changed': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), + ]), + 'name': 'Fan', + 'name_by_user': None, + }), + 'mac_id': '**REDACTED**', + 'manager': '**REDACTED**', + 'mode': None, + 'speed': None, + 'sub_device_no': None, + 'type': 'wifi-air', + 'uuid': '**REDACTED**', + }) +# --- diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py new file mode 100644 index 000000000000..eb802bb41b88 --- /dev/null +++ b/tests/components/vesync/test_diagnostics.py @@ -0,0 +1,99 @@ +"""Tests for the diagnostics data provided by the VeSync integration.""" +from unittest.mock import patch + +from pyvesync.helpers import Helpers +from syrupy import SnapshotAssertion +from syrupy.matchers import path_type + +from homeassistant.components.vesync.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from .common import ( + call_api_side_effect__no_devices, + call_api_side_effect__single_fan, + call_api_side_effect__single_humidifier, +) + +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_async_get_config_entry_diagnostics__no_devices( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: ConfigEntry, + config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + with patch.object(Helpers, "call_api") as call_api: + call_api.side_effect = call_api_side_effect__no_devices + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(diag, dict) + assert diag == snapshot + + +async def test_async_get_config_entry_diagnostics__single_humidifier( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: ConfigEntry, + config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + with patch.object(Helpers, "call_api") as call_api: + call_api.side_effect = call_api_side_effect__single_humidifier + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert isinstance(diag, dict) + assert diag == snapshot + + +async def test_async_get_device_diagnostics__single_fan( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: ConfigEntry, + config: ConfigType, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + with patch.object(Helpers, "call_api") as call_api: + call_api.side_effect = call_api_side_effect__single_fan + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "abcdefghabcdefghabcdefghabcdefgh")}, + ) + assert device is not None + + diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) + + assert isinstance(diag, dict) + assert diag == snapshot( + matcher=path_type( + { + "home_assistant.entities.0.state.last_changed": (str,), + "home_assistant.entities.0.state.last_updated": (str,), + "home_assistant.entities.1.state.last_changed": (str,), + "home_assistant.entities.1.state.last_updated": (str,), + "home_assistant.entities.2.state.last_changed": (str,), + "home_assistant.entities.2.state.last_updated": (str,), + } + ) + ) diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py new file mode 100644 index 000000000000..0f77c9cbf35e --- /dev/null +++ b/tests/components/vesync/test_init.py @@ -0,0 +1,103 @@ +"""Tests for the init module.""" +from unittest.mock import Mock, patch + +import pytest +from pyvesync import VeSync + +from homeassistant.components.vesync import async_setup_entry +from homeassistant.components.vesync.const import ( + DOMAIN, + VS_FANS, + VS_LIGHTS, + VS_MANAGER, + VS_SENSORS, + VS_SWITCHES, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + + +async def test_async_setup_entry__not_login( + hass: HomeAssistant, + config_entry: ConfigEntry, + manager: VeSync, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup does not create config entry when not logged in.""" + manager.login = Mock(return_value=False) + + with patch.object( + hass.config_entries, "async_forward_entry_setups" + ) as setups_mock, patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as setup_mock, patch( + "homeassistant.components.vesync.async_process_devices" + ) as process_mock, patch.object( + hass.services, "async_register" + ) as register_mock: + assert not await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + assert setups_mock.call_count == 0 + assert setup_mock.call_count == 0 + assert process_mock.call_count == 0 + assert register_mock.call_count == 0 + + assert manager.login.call_count == 1 + assert DOMAIN not in hass.data + assert "Unable to login to the VeSync server" in caplog.text + + +async def test_async_setup_entry__no_devices( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync +) -> None: + """Test setup connects to vesync and creates empty config when no devices.""" + with patch.object( + hass.config_entries, "async_forward_entry_setups" + ) as setups_mock, patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as setup_mock: + assert await async_setup_entry(hass, config_entry) + # Assert platforms loaded + await hass.async_block_till_done() + assert setups_mock.call_count == 1 + assert setups_mock.call_args.args[0] == config_entry + assert setups_mock.call_args.args[1] == [] + assert setup_mock.call_count == 0 + + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert not hass.data[DOMAIN][VS_SWITCHES] + assert not hass.data[DOMAIN][VS_FANS] + assert not hass.data[DOMAIN][VS_LIGHTS] + assert not hass.data[DOMAIN][VS_SENSORS] + + +async def test_async_setup_entry__loads_fans( + hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan +) -> None: + """Test setup connects to vesync and loads fan platform.""" + fans = [fan] + manager.fans = fans + manager._dev_list = { + "fans": fans, + } + + with patch.object( + hass.config_entries, "async_forward_entry_setups" + ) as setups_mock, patch.object( + hass.config_entries, "async_forward_entry_setup" + ) as setup_mock: + assert await async_setup_entry(hass, config_entry) + # Assert platforms loaded + await hass.async_block_till_done() + assert setups_mock.call_count == 1 + assert setups_mock.call_args.args[0] == config_entry + assert setups_mock.call_args.args[1] == [Platform.FAN, Platform.SENSOR] + assert setup_mock.call_count == 0 + assert manager.login.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert not hass.data[DOMAIN][VS_SWITCHES] + assert hass.data[DOMAIN][VS_FANS] == [fan] + assert not hass.data[DOMAIN][VS_LIGHTS] + assert hass.data[DOMAIN][VS_SENSORS] == [fan] From 9fc6700c5acd0601ff2db792463ffa1d0891b66c Mon Sep 17 00:00:00 2001 From: Marius Stedjan Date: Wed, 1 Mar 2023 06:47:47 +0100 Subject: [PATCH 0141/1058] Add ZWaveDiscoverySchema for Merten 507801 (#88342) * Add ZWaveDiscoverySchema for Merten 507801 * Add discovery tests to Merten 507801 z-wave device * Add Z-Wave discovery schemas for Merten 507801 to disable endpoint 2 by default * Add more discovery tests for Merten 507801 z-wave device --- .../components/zwave_js/discovery.py | 47 ++ tests/components/zwave_js/conftest.py | 14 + .../fixtures/cover_merten_507801_state.json | 798 ++++++++++++++++++ tests/components/zwave_js/test_discovery.py | 39 + 4 files changed, 898 insertions(+) create mode 100644 tests/components/zwave_js/fixtures/cover_merten_507801_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index fa0c3dc13de0..5dfab3077e49 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -390,6 +390,53 @@ DISCOVERY_SCHEMAS = [ product_type={0x0003}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), + # Merten 507801 Connect Roller Shutter + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="window_shutter", + manufacturer_id={0x007A}, + product_id={0x0001}, + product_type={0x8003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={0, 1}, + type={ValueType.NUMBER}, + ), + assumed_state=True, + ), + # Merten 507801 Connect Roller Shutter. + # Disable endpoint 2, as it has no practical function. CC: Switch_Multilevel + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="window_shutter", + manufacturer_id={0x007A}, + product_id={0x0001}, + product_type={0x8003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + assumed_state=True, + entity_registry_enabled_default=False, + ), + # Merten 507801 Connect Roller Shutter. + # Disable endpoint 2, as it has no practical function. CC: Protection + ZWaveDiscoverySchema( + platform=Platform.SELECT, + manufacturer_id={0x007A}, + product_id={0x0001}, + product_type={0x8003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.PROTECTION}, + property={LOCAL_PROPERTY, RF_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + entity_registry_enabled_default=False, + ), # Vision Security ZL7432 In Wall Dual Relay Switch ZWaveDiscoverySchema( platform=Platform.SWITCH, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 1f332f04fd4a..f20c814bdc33 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -458,6 +458,12 @@ def fibaro_fgr222_shutter_state_fixture(): return json.loads(load_fixture("zwave_js/cover_fibaro_fgr222_state.json")) +@pytest.fixture(name="merten_507801_state", scope="session") +def merten_507801_state_fixture(): + """Load the Merten 507801 Shutter node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_merten_507801_state.json")) + + @pytest.fixture(name="aeon_smart_switch_6_state", scope="session") def aeon_smart_switch_6_state_fixture(): """Load the AEON Labs (ZW096) Smart Switch 6 node state fixture data.""" @@ -952,6 +958,14 @@ def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state): return node +@pytest.fixture(name="merten_507801") +def merten_507801_cover_fixture(client, merten_507801_state): + """Mock a Merten 507801 Shutter node.""" + node = Node(client, copy.deepcopy(merten_507801_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="aeon_smart_switch_6") def aeon_smart_switch_6_fixture(client, aeon_smart_switch_6_state): """Mock an AEON Labs (ZW096) Smart Switch 6 node.""" diff --git a/tests/components/zwave_js/fixtures/cover_merten_507801_state.json b/tests/components/zwave_js/fixtures/cover_merten_507801_state.json new file mode 100644 index 000000000000..f7b8dbd8c7cc --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_merten_507801_state.json @@ -0,0 +1,798 @@ +{ + "nodeId": 41, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 122, + "productId": 1, + "productType": 32771, + "firmwareVersion": "2.2", + "deviceConfig": { + "filename": "/opt/node_modules/@zwave-js/config/config/devices/0x007a/507801.json", + "isEmbedded": false, + "manufacturer": "Merten", + "manufacturerId": 122, + "label": "507801", + "description": "Connect Roller Shutter", + "devices": [ + { + "productType": 32771, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Triple click button", + "exclusion": "Triple click button", + "reset": "Triple click button, then click and hold for 5 seconds", + "manual": "https://download.schneider-electric.com/files?p_Doc_Ref=MTN507801_HW_2008_43_EN&p_enDocType=User+guide&p_File_Name=MTN507801_HW_2008_43_EN.pdf" + } + }, + "label": "507801", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": true, + "individualEndpointCount": 2, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 41, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 9, + "label": "Window Covering" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 2, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 41, + "index": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 9, + "label": "Window Covering" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 41, + "index": 2, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 9, + "label": "Window Covering" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 1, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 176, + "propertyName": "Changeover Delay", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "For motor protection", + "label": "Changeover Delay", + "default": 10, + "min": 0, + "max": 255, + "unit": "0.1 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 177, + "propertyName": "Travel Time Up, Byte 1", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Travel Time Up, Byte 1", + "default": 4, + "min": 0, + "max": 255, + "unit": "25.6 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 178, + "propertyName": "Travel Time Up, Byte 2", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Travel Time Up, Byte 2", + "default": 176, + "min": 0, + "max": 255, + "unit": "0.1 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 176 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 179, + "propertyName": "Travel Time Down, Byte 1", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Travel Time Down, Byte 1", + "default": 4, + "min": 0, + "max": 255, + "unit": "25.6 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 180, + "propertyName": "Travel Time Down, Byte 2", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Travel Time Down, Byte 2", + "default": 176, + "min": 0, + "max": 255, + "unit": "0.1 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 176 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 122 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 32771 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "2.27" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["2.2"] + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration" + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value" + } + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "2": "NoOperationPossible" + } + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl" + } + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Node ID with exclusive control", + "min": 1, + "max": 232 + } + }, + { + "endpoint": 1, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection timeout", + "min": 0, + "max": 255 + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration" + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value" + } + }, + { + "endpoint": 2, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "1": "ProtectedBySequence", + "2": "NoOperationPossible" + } + }, + "value": 2 + }, + { + "endpoint": 2, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl", + "2": "NoResponse" + } + }, + "value": 1 + }, + { + "endpoint": 2, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Node ID with exclusive control", + "min": 1, + "max": 232 + } + }, + { + "endpoint": 2, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection timeout", + "min": 0, + "max": 255 + } + } + ], + "isFrequentListening": false, + "maxDataRate": 9600, + "supportedDataRates": [9600], + "protocolVersion": 1, + "supportsBeaming": false, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 9, + "label": "Window Covering" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x007a:0x8003:0x0001:2.2", + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 601947f00981..66969c51ff01 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -10,6 +10,7 @@ from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er async def test_iblinds_v2(hass: HomeAssistant, client, iblinds_v2, integration) -> None: @@ -100,3 +101,41 @@ async def test_dynamic_climate_data_discovery_template_failure( DynamicCurrentTempClimateDataTemplate().resolve_data( node.values[f"{node.node_id}-49-0-Ultraviolet"] ) + + +async def test_merten_507801(hass, client, merten_507801, integration): + """Test that Merten 507801 multilevel switch value is discovered as a cover.""" + node = merten_507801 + assert node.device_class.specific.label == "Unused" + + state = hass.states.get("light.connect_roller_shutter") + assert not state + + state = hass.states.get("cover.connect_roller_shutter") + assert state + + +async def test_merten_507801_disabled_enitites( + hass, client, merten_507801, integration +): + """Test that Merten 507801 entities created by endpoint 2 are disabled.""" + registry = er.async_get(hass) + entity_ids = [ + "cover.connect_roller_shutter_2", + "select.connect_roller_shutter_local_protection_state_2", + "select.connect_roller_shutter_rf_protection_state_2", + ] + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state is None + entry = registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entry + assert updated_entry.disabled is False From 95dd62186e33910a7936220a52e281818e356406 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 07:43:33 +0100 Subject: [PATCH 0142/1058] Use json_loads_object in arwn (#88611) --- homeassistant/components/arwn/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index 420ffb2d8a8d..f03734e63dff 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -1,7 +1,6 @@ """Support for collecting data from the ARWN project.""" from __future__ import annotations -import json import logging from homeassistant.components import mqtt @@ -11,6 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify +from homeassistant.util.json import json_loads_object _LOGGER = logging.getLogger(__name__) @@ -102,7 +102,7 @@ async def async_setup_platform( """Set up the ARWN platform.""" @callback - def async_sensor_event_received(msg): + def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None: """Process events as sensors. When a new event on our topic (arwn/#) is received we map it @@ -115,7 +115,7 @@ async def async_setup_platform( This lets us dynamically incorporate sensors without any configuration on our side. """ - event = json.loads(msg.payload) + event = json_loads_object(msg.payload) sensors = discover_sensors(msg.topic, event) if not sensors: return From 50f908ce2d0d4bdce892c4f451f918cc1a824fce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 07:44:29 +0100 Subject: [PATCH 0143/1058] Use load_json_object in fitbit (#88585) * Use load_json_object in fitbit * Remove unnecessary cast --- homeassistant/components/fitbit/sensor.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index d703699a4338..c53c01c84a75 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -27,7 +27,7 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.json import save_json from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.json import load_json +from homeassistant.util.json import load_json_object from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( @@ -85,7 +85,7 @@ def request_app_setup( """Handle configuration updates.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) if os.path.isfile(config_path): - config_file = load_json(config_path) + config_file = load_json_object(config_path) if config_file == DEFAULT_CONFIG: error_msg = ( f"You didn't correctly modify {FITBIT_CONFIG_FILE}, please try" @@ -161,7 +161,7 @@ def setup_platform( """Set up the Fitbit sensor.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) if os.path.isfile(config_path): - config_file: ConfigType = cast(ConfigType, load_json(config_path)) + config_file = load_json_object(config_path) if config_file == DEFAULT_CONFIG: request_app_setup( hass, config, add_entities, config_path, discovery_info=None @@ -175,13 +175,10 @@ def setup_platform( if "fitbit" in _CONFIGURING: configurator.request_done(hass, _CONFIGURING.pop("fitbit")) - access_token: str | None = config_file.get(ATTR_ACCESS_TOKEN) - refresh_token: str | None = config_file.get(ATTR_REFRESH_TOKEN) - expires_at: int | None = config_file.get(ATTR_LAST_SAVED_AT) if ( - access_token is not None - and refresh_token is not None - and expires_at is not None + (access_token := config_file.get(ATTR_ACCESS_TOKEN)) is not None + and (refresh_token := config_file.get(ATTR_REFRESH_TOKEN)) is not None + and (expires_at := config_file.get(ATTR_LAST_SAVED_AT)) is not None ): authd_client = Fitbit( config_file.get(CONF_CLIENT_ID), @@ -192,7 +189,7 @@ def setup_platform( refresh_cb=lambda x: None, ) - if int(time.time()) - expires_at > 3600: + if int(time.time()) - cast(int, expires_at) > 3600: authd_client.client.refresh_token() user_profile = authd_client.user_profile_get()["user"] From f2b736fad0bb1f9c2796551936efa3a54291dbdb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 08:02:16 +0100 Subject: [PATCH 0144/1058] Adjust entity registry access in core platforms (#88944) * Adjust entity registry access in platforms * Adjust more core components --- homeassistant/auth/permissions/models.py | 9 +++---- .../alarm_control_panel/device_action.py | 6 ++--- .../alarm_control_panel/device_condition.py | 10 +++++--- .../alarm_control_panel/device_trigger.py | 6 ++--- .../components/button/device_action.py | 6 ++--- .../components/button/device_trigger.py | 6 ++--- .../components/climate/device_action.py | 6 ++--- .../components/climate/device_condition.py | 10 +++++--- .../components/climate/device_trigger.py | 6 ++--- homeassistant/components/config/automation.py | 4 ++-- homeassistant/components/config/scene.py | 4 ++-- .../components/cover/device_action.py | 6 ++--- .../components/cover/device_condition.py | 10 +++++--- .../components/cover/device_trigger.py | 6 ++--- .../device_tracker/device_condition.py | 10 +++++--- .../device_tracker/device_trigger.py | 6 ++--- .../components/fan/device_condition.py | 10 +++++--- .../components/humidifier/device_action.py | 6 ++--- .../components/humidifier/device_condition.py | 10 +++++--- .../components/humidifier/device_trigger.py | 6 ++--- .../components/lock/device_action.py | 6 ++--- .../components/lock/device_condition.py | 10 +++++--- .../components/lock/device_trigger.py | 6 ++--- .../media_player/device_condition.py | 10 +++++--- .../components/media_player/device_trigger.py | 6 ++--- .../components/number/device_action.py | 6 ++--- homeassistant/components/person/__init__.py | 4 ++-- .../components/recorder/statistics.py | 4 ++-- homeassistant/components/search/__init__.py | 24 ++++++++----------- .../components/select/device_action.py | 6 ++--- .../components/select/device_condition.py | 10 +++++--- .../components/select/device_trigger.py | 6 ++--- .../components/text/device_action.py | 6 ++--- .../components/vacuum/device_action.py | 6 ++--- .../components/vacuum/device_condition.py | 10 +++++--- .../components/vacuum/device_trigger.py | 6 ++--- .../components/water_heater/device_action.py | 6 ++--- 37 files changed, 154 insertions(+), 121 deletions(-) diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py index aa1a777ced26..9b9c384c74d2 100644 --- a/homeassistant/auth/permissions/models.py +++ b/homeassistant/auth/permissions/models.py @@ -6,15 +6,12 @@ from typing import TYPE_CHECKING import attr if TYPE_CHECKING: - from homeassistant.helpers import ( - device_registry as dev_reg, - entity_registry as ent_reg, - ) + from homeassistant.helpers import device_registry as dr, entity_registry as er @attr.s(slots=True) class PermissionLookup: """Class to hold data for permission lookups.""" - entity_registry: ent_reg.EntityRegistry = attr.ib() - device_registry: dev_reg.DeviceRegistry = attr.ib() + entity_registry: er.EntityRegistry = attr.ib() + device_registry: dr.DeviceRegistry = attr.ib() diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index dd0c3d03a437..de4f3df257a0 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -21,7 +21,7 @@ from homeassistant.const import ( SERVICE_ALARM_TRIGGER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -57,11 +57,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Alarm control panel devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index 4764d5cfcbef..a097aa98535a 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -21,7 +21,11 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -64,11 +68,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Alarm control panel devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index 303243d66cbe..9106942c5e55 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -57,11 +57,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Alarm control panel devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py index 70033729692b..8398b4990cd6 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -31,7 +31,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for button devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_DEVICE_ID: device_id, @@ -39,7 +39,7 @@ async def async_get_actions( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "press", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/button/device_trigger.py b/homeassistant/components/button/device_trigger.py index 673806be7d2a..fbf054996c3d 100644 --- a/homeassistant/components/button/device_trigger.py +++ b/homeassistant/components/button/device_trigger.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -36,7 +36,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for button devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_PLATFORM: "device", @@ -45,7 +45,7 @@ async def async_get_triggers( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "pressed", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 3c9934d5cbf1..0119ad658015 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -44,11 +44,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Climate devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index c6179d822157..97dc27cfa090 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -13,7 +13,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -45,11 +49,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Climate devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 0b0bedb49bba..005e744b53ff 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -20,7 +20,7 @@ from homeassistant.const import ( PERCENTAGE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -62,11 +62,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Climate devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 5a39b786e278..72a493f8c1f0 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -8,7 +8,7 @@ from homeassistant.components.automation.config import ( ) from homeassistant.config import AUTOMATION_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView @@ -23,7 +23,7 @@ async def async_setup(hass): if action != ACTION_DELETE: return - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(DOMAIN, DOMAIN, config_key) diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py index befbfd052af4..037cd55d6a03 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -5,7 +5,7 @@ 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.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView @@ -19,7 +19,7 @@ async def async_setup(hass): await hass.services.async_call(DOMAIN, SERVICE_RELOAD) return - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(DOMAIN, HA_DOMAIN, config_key) diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index c3c0e928f0ff..9b2bb05bb0f7 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -18,7 +18,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -63,11 +63,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Cover devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index bb66d54b79bc..6144bdb6dbf6 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -18,7 +18,11 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -66,11 +70,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Cover devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index b0be418f3123..aad225c80396 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -71,11 +71,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Cover devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 1a6adabda63f..96ee70baca82 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -13,7 +13,11 @@ from homeassistant.const import ( STATE_HOME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -33,11 +37,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Device tracker devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index 231fab65d352..150b58722754 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_ZONE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -38,11 +38,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Device Tracker devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/fan/device_condition.py b/homeassistant/components/fan/device_condition.py index 7e27ea29f98f..d4bd5f2e419d 100644 --- a/homeassistant/components/fan/device_condition.py +++ b/homeassistant/components/fan/device_condition.py @@ -14,7 +14,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -34,11 +38,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Fan devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/humidifier/device_action.py b/homeassistant/components/humidifier/device_action.py index 773caa72f952..1c027ba22e62 100644 --- a/homeassistant/components/humidifier/device_action.py +++ b/homeassistant/components/humidifier/device_action.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -48,11 +48,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Humidifier devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = await toggle_entity.async_get_actions(hass, device_id, DOMAIN) # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py index 949b25fdd150..05812e35a362 100644 --- a/homeassistant/components/humidifier/device_condition.py +++ b/homeassistant/components/humidifier/device_condition.py @@ -15,7 +15,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_capability, get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -41,11 +45,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Humidifier devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py index ed1620c51a2d..5fbb248a8bc9 100644 --- a/homeassistant/components/humidifier/device_trigger.py +++ b/homeassistant/components/humidifier/device_trigger.py @@ -22,7 +22,7 @@ from homeassistant.const import ( PERCENTAGE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -56,11 +56,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Humidifier devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/lock/device_action.py b/homeassistant/components/lock/device_action.py index 3ff8d10c7a29..01e7b21d4b6e 100644 --- a/homeassistant/components/lock/device_action.py +++ b/homeassistant/components/lock/device_action.py @@ -14,7 +14,7 @@ from homeassistant.const import ( SERVICE_UNLOCK, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -35,11 +35,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Lock devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index cdaa02de6189..c439fe99d148 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -17,7 +17,11 @@ from homeassistant.const import ( STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -45,11 +49,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Lock devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 9fc35fb13526..ec996d4f0b29 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -19,7 +19,7 @@ from homeassistant.const import ( STATE_UNLOCKING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -40,11 +40,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Lock devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/media_player/device_condition.py b/homeassistant/components/media_player/device_condition.py index 3bf6c5956faf..9e3981ed9833 100644 --- a/homeassistant/components/media_player/device_condition.py +++ b/homeassistant/components/media_player/device_condition.py @@ -18,7 +18,11 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -45,11 +49,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Media player devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/media_player/device_trigger.py b/homeassistant/components/media_player/device_trigger.py index 9b61c89dafb0..58fc0aca84fa 100644 --- a/homeassistant/components/media_player/device_trigger.py +++ b/homeassistant/components/media_player/device_trigger.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -52,11 +52,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Media player entities.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = await entity.async_get_triggers(hass, device_id, DOMAIN) # Get all the integration entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/number/device_action.py b/homeassistant/components/number/device_action.py index e4311f50dd25..971f8d5a514d 100644 --- a/homeassistant/components/number/device_action.py +++ b/homeassistant/components/number/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,11 +32,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Number.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 523d21aa69c0..e3b719d166fc 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -42,7 +42,7 @@ from homeassistant.core import ( from homeassistant.helpers import ( collection, config_validation as cv, - entity_registry, + entity_registry as er, service, ) from homeassistant.helpers.entity_component import EntityComponent @@ -226,7 +226,7 @@ class PersonStorageCollection(collection.StorageCollection): """Load the Storage collection.""" await super().async_load() self.hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, self._entity_registry_updated + er.EVENT_ENTITY_REGISTRY_UPDATED, self._entity_registry_updated ) async def _entity_registry_updated(self, event) -> None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 294c52176230..2a958d3b622c 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -29,7 +29,7 @@ import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.start import async_at_start from homeassistant.helpers.storage import STORAGE_DIR @@ -338,7 +338,7 @@ def async_setup(hass: HomeAssistant) -> None: def setup_entity_registry_event_handler(hass: HomeAssistant) -> None: """Subscribe to event registry events.""" hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + er.EVENT_ENTITY_REGISTRY_UPDATED, _async_entity_id_changed, event_filter=entity_registry_changed_filter, ) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 70702f351f6f..b574081d5d42 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components import automation, group, person, script, websocket_api from homeassistant.components.homeassistant import scene from homeassistant.core import HomeAssistant, callback, split_entity_id -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import entity_sources as get_entity_sources from homeassistant.helpers.typing import ConfigType @@ -53,8 +53,8 @@ def websocket_search_related( """Handle search.""" searcher = Searcher( hass, - device_registry.async_get(hass), - entity_registry.async_get(hass), + dr.async_get(hass), + er.async_get(hass), get_entity_sources(hass), ) connection.send_result( @@ -86,8 +86,8 @@ class Searcher: def __init__( self, hass: HomeAssistant, - device_reg: device_registry.DeviceRegistry, - entity_reg: entity_registry.EntityRegistry, + device_reg: dr.DeviceRegistry, + entity_reg: er.EntityRegistry, entity_sources: dict[str, dict[str, str]], ) -> None: """Search results.""" @@ -141,12 +141,10 @@ class Searcher: @callback def _resolve_area(self, area_id) -> None: """Resolve an area.""" - for device in device_registry.async_entries_for_area(self._device_reg, area_id): + for device in dr.async_entries_for_area(self._device_reg, area_id): self._add_or_resolve("device", device.id) - for entity_entry in entity_registry.async_entries_for_area( - self._entity_reg, area_id - ): + for entity_entry in er.async_entries_for_area(self._entity_reg, area_id): self._add_or_resolve("entity", entity_entry.entity_id) for entity_id in script.scripts_with_area(self.hass, area_id): @@ -178,12 +176,12 @@ class Searcher: Will only be called if config entry is an entry point. """ - for device_entry in device_registry.async_entries_for_config_entry( + for device_entry in dr.async_entries_for_config_entry( self._device_reg, config_entry_id ): self._add_or_resolve("device", device_entry.id) - for entity_entry in entity_registry.async_entries_for_config_entry( + for entity_entry in er.async_entries_for_config_entry( self._entity_reg, config_entry_id ): self._add_or_resolve("entity", entity_entry.entity_id) @@ -203,9 +201,7 @@ class Searcher: # We do not resolve device_entry.via_device_id because that # device is not related data-wise inside HA. - for entity_entry in entity_registry.async_entries_for_device( - self._entity_reg, device_id - ): + for entity_entry in er.async_entries_for_device(self._entity_reg, device_id): self._add_or_resolve("entity", entity_entry.entity_id) for entity_id in script.scripts_with_device(self.hass, device_id): diff --git a/homeassistant/components/select/device_action.py b/homeassistant/components/select/device_action.py index ce1cea89c906..d553cdf30439 100644 --- a/homeassistant/components/select/device_action.py +++ b/homeassistant/components/select/device_action.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -74,7 +74,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Select devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_DEVICE_ID: device_id, @@ -89,7 +89,7 @@ async def async_get_actions( SERVICE_SELECT_OPTION, SERVICE_SELECT_PREVIOUS, ) - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/select/device_condition.py b/homeassistant/components/select/device_condition.py index 6e6a3c704b36..13280ba4f0e2 100644 --- a/homeassistant/components/select/device_condition.py +++ b/homeassistant/components/select/device_condition.py @@ -13,7 +13,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_capability from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -38,7 +42,7 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Select devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_CONDITION: "device", @@ -47,7 +51,7 @@ async def async_get_conditions( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "selected_option", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/select/device_trigger.py b/homeassistant/components/select/device_trigger.py index 897ed855a5e7..8e8267cb5e0f 100644 --- a/homeassistant/components/select/device_trigger.py +++ b/homeassistant/components/select/device_trigger.py @@ -20,7 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import get_capability from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -44,7 +44,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Select devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ { CONF_PLATFORM: "device", @@ -53,7 +53,7 @@ async def async_get_triggers( CONF_ENTITY_ID: entry.entity_id, CONF_TYPE: "current_option_changed", } - for entry in entity_registry.async_entries_for_device(registry, device_id) + for entry in er.async_entries_for_device(registry, device_id) if entry.domain == DOMAIN ] diff --git a/homeassistant/components/text/device_action.py b/homeassistant/components/text/device_action.py index 3d14da9bdb85..89fbbc7fbc72 100644 --- a/homeassistant/components/text/device_action.py +++ b/homeassistant/components/text/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,11 +32,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Text.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions: list[dict[str, str]] = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/vacuum/device_action.py b/homeassistant/components/vacuum/device_action.py index e8fe53b08ae6..9b53c7612477 100644 --- a/homeassistant/components/vacuum/device_action.py +++ b/homeassistant/components/vacuum/device_action.py @@ -11,7 +11,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -31,11 +31,11 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Vacuum devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/vacuum/device_condition.py b/homeassistant/components/vacuum/device_condition.py index fa76dd800ecd..cf5b09346639 100644 --- a/homeassistant/components/vacuum/device_condition.py +++ b/homeassistant/components/vacuum/device_condition.py @@ -12,7 +12,11 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,11 +36,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for Vacuum devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py index c90aa1756e4e..6a2646922b67 100644 --- a/homeassistant/components/vacuum/device_trigger.py +++ b/homeassistant/components/vacuum/device_trigger.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -35,11 +35,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Vacuum devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/homeassistant/components/water_heater/device_action.py b/homeassistant/components/water_heater/device_action.py index 6bc7e1ca635a..8ae75527abcd 100644 --- a/homeassistant/components/water_heater/device_action.py +++ b/homeassistant/components/water_heater/device_action.py @@ -13,7 +13,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -33,10 +33,10 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for Water Heater devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue From b3d6f098d2af00f9955a11deaedd4d636463d7ec Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 08:02:34 +0100 Subject: [PATCH 0145/1058] Adjust entity registry access in integrations (1) (#88946) --- homeassistant/components/rachio/entity.py | 4 ++-- homeassistant/components/rainmachine/util.py | 4 ++-- .../components/ruckus_unleashed/__init__.py | 9 ++++----- .../ruckus_unleashed/device_tracker.py | 4 ++-- homeassistant/components/sleepiq/entity.py | 4 ++-- homeassistant/components/sonos/speaker.py | 4 ++-- .../components/switcher_kis/__init__.py | 6 +++--- .../components/switcher_kis/button.py | 6 ++---- .../components/switcher_kis/climate.py | 6 ++---- homeassistant/components/switcher_kis/cover.py | 6 ++---- .../components/switcher_kis/sensor.py | 6 ++---- .../components/switcher_kis/switch.py | 6 ++---- .../components/tplink_omada/entity.py | 4 ++-- .../components/traccar/device_tracker.py | 5 ++--- homeassistant/components/tradfri/sensor.py | 4 ++-- homeassistant/components/upnp/__init__.py | 8 ++++---- homeassistant/components/velbus/__init__.py | 10 +++------- homeassistant/components/wiffi/__init__.py | 4 ++-- homeassistant/components/zwave_js/__init__.py | 18 +++++++++--------- .../components/zwave_js/device_action.py | 7 +++---- .../components/zwave_js/device_trigger.py | 8 ++++---- homeassistant/components/zwave_me/__init__.py | 7 +++---- 22 files changed, 61 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index 1bb971e3e016..a109c4b99f75 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -1,6 +1,6 @@ """Adapter to wrap the rachiopy api for home assistant.""" -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DEFAULT_NAME, DOMAIN @@ -25,7 +25,7 @@ class RachioDevice(Entity): }, connections={ ( - device_registry.CONNECTION_NETWORK_MAC, + dr.CONNECTION_NETWORK_MAC, self._controller.mac_address, ) }, diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index 67ffc83d5bd1..d4131fdb022c 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -55,7 +55,7 @@ def async_finish_entity_domain_replacements( entity_replacement_strategies: Iterable[EntityDomainReplacementStrategy], ) -> None: """Remove old entities and create a repairs issue with info on their replacement.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for strategy in entity_replacement_strategies: try: [registry_entry] = [ diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index 5861486457fc..f276c0f8fc2a 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -6,8 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers import device_registry as dr from .const import ( API_AP, @@ -43,13 +42,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: system_info = await ruckus.system_info() - registry = device_registry.async_get(hass) + registry = dr.async_get(hass) ap_info = await ruckus.ap_info() for device in ap_info[API_AP][API_ID].values(): registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(CONNECTION_NETWORK_MAC, device[API_MAC])}, - identifiers={(CONNECTION_NETWORK_MAC, device[API_MAC])}, + connections={(dr.CONNECTION_NETWORK_MAC, device[API_MAC])}, + identifiers={(dr.CONNECTION_NETWORK_MAC, device[API_MAC])}, manufacturer=MANUFACTURER, name=device[API_DEVICE_NAME], model=device[API_MODEL], diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 5e8998c47ddb..dd6d7fd67642 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -37,7 +37,7 @@ async def async_setup_entry( coordinator.async_add_listener(router_update) ) - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) restore_entities(registry, coordinator, entry, async_add_entities, tracked) diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index d4ca2c894da4..e6eeaa98c227 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -5,7 +5,7 @@ from typing import TypeVar from asyncsleepiq import SleepIQBed, SleepIQSleeper from homeassistant.core import callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -21,7 +21,7 @@ _SleepIQCoordinatorT = TypeVar( def device_from_bed(bed: SleepIQBed) -> DeviceInfo: """Create a device given a bed.""" return DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, bed.mac_addr)}, + connections={(dr.CONNECTION_NETWORK_MAC, bed.mac_addr)}, manufacturer="SleepNumber", name=bed.name, model=bed.model, diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 3e66f5690b41..f97d134c9c25 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -23,7 +23,7 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -837,7 +837,7 @@ class SonosSpeaker: # Skip updating existing single speakers in polling mode return - entity_registry = ent_reg.async_get(self.hass) + entity_registry = er.async_get(self.hass) sonos_group = [] sonos_group_entities = [] diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index bc352989799d..abb18a19ed32 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_DEVICE_ID, EVENT_HOMEASSISTANT_STOP, Platfo from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, - device_registry, + device_registry as dr, update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -165,10 +165,10 @@ class SwitcherDataUpdateCoordinator( @callback def async_setup(self) -> None: """Set up the coordinator.""" - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) dev_reg.async_get_or_create( config_entry_id=self.entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac_address)}, + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}, identifiers={(DOMAIN, self.device_id)}, manufacturer="Switcher", name=self.name, diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index a8e4f503d171..ec2f4c0bc904 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -132,9 +132,7 @@ class SwitcherThermostatButtonEntity( self._attr_name = f"{coordinator.name} {description.name}" self._attr_unique_id = f"{coordinator.mac_address}-{description.key}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) async def async_press(self) -> None: diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 57d4d9977f23..be966d67eefa 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -29,7 +29,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -94,9 +94,7 @@ class SwitcherClimateEntity( self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) self._attr_min_temp = remote.min_temperature diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 584f3d7124fa..1d72184ad4d3 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -70,9 +70,7 @@ class SwitcherCoverEntity( self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) self._update_data() diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index c75d27d67d4b..2c74f14cb5cb 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricCurrent, UnitOfPower from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -118,9 +118,7 @@ class SwitcherSensorEntity( f"{coordinator.device_id}-{coordinator.mac_address}-{attribute}" ) self._attr_device_info = { - "connections": { - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + "connections": {(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} } @property diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 9d1b5d4bdc50..caed3c3c3204 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, - device_registry, + device_registry as dr, entity_platform, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -92,9 +92,7 @@ class SwitcherBaseSwitchEntity( self._attr_name = coordinator.name self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, coordinator.mac_address) - } + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.mac_address)} ) @callback diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index 3e7f21409bce..c3cc1433b9cc 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -1,7 +1,7 @@ """Base entity definitions.""" from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -25,7 +25,7 @@ class OmadaSwitchDeviceEntity( def device_info(self) -> DeviceInfo: """Return information about the device.""" return DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, self.device.mac)}, + connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, identifiers={(DOMAIN, (self.device.mac))}, manufacturer="TP-Link", model=self.device.model_display_name, diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index bbc089de0c9f..4581e2868196 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -36,9 +36,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval @@ -153,7 +152,7 @@ async def async_setup_entry( ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 689964cb1517..81cce80aa739 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .base_class import TradfriBaseEntity @@ -108,7 +108,7 @@ SENSOR_DESCRIPTIONS_FAN: tuple[TradfriSensorEntityDescription, ...] = ( @callback def _migrate_old_unique_ids(hass: HomeAssistant, old_unique_id: str, key: str) -> None: """Migrate unique IDs to the new format.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(Platform.SENSOR, DOMAIN, old_unique_id) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 7ddec4e3fbee..5f77d58c5ea8 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -12,7 +12,7 @@ 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, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( CONFIG_ENTRY_HOST, @@ -118,11 +118,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if device.serial_number: identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number)) - connections = {(device_registry.CONNECTION_UPNP, device.udn)} + connections = {(dr.CONNECTION_UPNP, device.udn)} if device_mac_address: - connections.add((device_registry.CONNECTION_NETWORK_MAC, device_mac_address)) + connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address)) - dev_registry = device_registry.async_get(hass) + dev_registry = dr.async_get(hass) device_entry = dev_registry.async_get_device( identifiers=identifiers, connections=connections ) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index fc451ff2626b..a51cef0a56c0 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -13,9 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import device_registry -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.storage import STORAGE_DIR from .const import ( @@ -55,10 +53,8 @@ async def velbus_connect_task( def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: """Migrate old device indentifiers.""" - dev_reg = device_registry.async_get(hass) - devices: list[DeviceEntry] = device_registry.async_entries_for_config_entry( - dev_reg, entry_id - ) + dev_reg = dr.async_get(hass) + devices: list[dr.DeviceEntry] = dr.async_entries_for_config_entry(dev_reg, entry_id) for device in devices: old_identifier = list(next(iter(device.identifiers))) if len(old_identifier) > 2: diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index d44c3aaefb78..a802535441a0 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT, CONF_TIMEOUT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -144,7 +144,7 @@ class WiffiEntity(Entity): """Initialize the base elements of a wiffi entity.""" self._id = generate_unique_id(device, metric) self._device_info = DeviceInfo( - connections={(device_registry.CONNECTION_NETWORK_MAC, device.mac_address)}, + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, identifiers={(DOMAIN, device.mac_address)}, manufacturer="stall.biz", model=device.moduletype, diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 8d3b93ad9db2..a2d729e22dcc 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.issue_registry import ( @@ -161,8 +161,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_delete_issue(hass, DOMAIN, "invalid_server_version") LOGGER.info("Connected to Zwave JS Server") - dev_reg = device_registry.async_get(hass) - ent_reg = entity_registry.async_get(hass) + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) services = ZWaveServices(hass, ent_reg, dev_reg) services.async_register() @@ -220,7 +220,7 @@ class DriverEvents: def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Set up the driver events instance.""" self.config_entry = entry - self.dev_reg = device_registry.async_get(hass) + self.dev_reg = dr.async_get(hass) self.hass = hass self.platform_setup_tasks: dict[str, asyncio.Task] = {} self.ready = asyncio.Event() @@ -240,7 +240,7 @@ class DriverEvents: await driver.async_disable_statistics() # Check for nodes that no longer exist and remove them - stored_devices = device_registry.async_entries_for_config_entry( + stored_devices = dr.async_entries_for_config_entry( self.dev_reg, self.config_entry.entry_id ) known_devices = [ @@ -311,7 +311,7 @@ class ControllerEvents: self.node_events = NodeEvents(hass, self) @callback - def remove_device(self, device: device_registry.DeviceEntry) -> None: + def remove_device(self, device: dr.DeviceEntry) -> None: """Remove device from registry.""" # note: removal of entity registry entry is handled by core self.dev_reg.async_remove_device(device.id) @@ -385,7 +385,7 @@ class ControllerEvents: self.remove_device(device) @callback - def register_node_in_dev_reg(self, node: ZwaveNode) -> device_registry.DeviceEntry: + def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry: """Register node in dev reg.""" driver = self.driver_events.driver device_id = get_device_id(driver, node) @@ -448,7 +448,7 @@ class NodeEvents: self.config_entry = controller_events.config_entry self.controller_events = controller_events self.dev_reg = controller_events.dev_reg - self.ent_reg = entity_registry.async_get(hass) + self.ent_reg = er.async_get(hass) self.hass = hass async def async_on_node_ready(self, node: ZwaveNode) -> None: @@ -532,7 +532,7 @@ class NodeEvents: async def async_handle_discovery_info( self, - device: device_registry.DeviceEntry, + device: dr.DeviceEntry, disc_info: ZwaveDiscoveryInfo, value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], ) -> None: diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 0172176d756d..3a585b44f586 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -25,8 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType, TemplateVarsType from .config_validation import VALUE_SCHEMA @@ -145,7 +144,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """List device actions for Z-Wave JS devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions: list[dict] = [] node = async_get_node_from_device_id(hass, device_id) @@ -179,7 +178,7 @@ async def async_get_actions( meter_endpoints: dict[int, dict[str, Any]] = defaultdict(dict) - for entry in entity_registry.async_entries_for_device( + for entry in er.async_entries_for_device( registry, device_id, include_disabled_entities=False ): # If an entry is unavailable, it is possible that the underlying value diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 067551109ebd..a0ac70ccb31d 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -22,8 +22,8 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, - device_registry, - entity_registry, + device_registry as dr, + entity_registry as er, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -255,14 +255,14 @@ async def async_get_triggers( CONF_DOMAIN: DOMAIN, } - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) node = async_get_node_from_device_id(hass, device_id, dev_reg) if node.client.driver and node.client.driver.controller.own_node == node: return triggers # We can add a node status trigger if the node status sensor is enabled - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = async_get_node_status_sensor_entity_id( hass, device_id, ent_reg, dev_reg ) diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index f47b77b29d1b..346831b34d9b 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -7,8 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry -from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -24,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: controller = hass.data[DOMAIN][entry.entry_id] = ZWaveMeController(hass, entry) if await controller.async_establish_connection(): await async_setup_platforms(hass, entry, controller) - registry = device_registry.async_get(hass) + registry = dr.async_get(hass) controller.remove_stale_devices(registry) return True raise ConfigEntryNotReady() @@ -83,7 +82,7 @@ class ZWaveMeController: """Send signal to update device.""" dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) - def remove_stale_devices(self, registry: DeviceRegistry): + def remove_stale_devices(self, registry: dr.DeviceRegistry): """Remove old-format devices in the registry.""" for device_id in self.device_ids: device = registry.async_get_device( From f69d76702adec64af469af3a6afe6294bfeced8b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 08:02:51 +0100 Subject: [PATCH 0146/1058] Adjust entity registry access in integrations (2) (#88947) --- homeassistant/components/iotawatt/sensor.py | 9 +++++---- homeassistant/components/ipma/weather.py | 8 +++----- homeassistant/components/keenetic_ndms2/__init__.py | 6 +++--- .../components/keenetic_ndms2/device_tracker.py | 4 ++-- homeassistant/components/kodi/device_trigger.py | 6 +++--- homeassistant/components/kodi/media_player.py | 4 ++-- homeassistant/components/kraken/sensor.py | 8 ++++---- homeassistant/components/mazda/__init__.py | 11 +++++++---- homeassistant/components/metoffice/__init__.py | 9 ++++----- homeassistant/components/mikrotik/device_tracker.py | 4 ++-- homeassistant/components/nam/__init__.py | 7 +++---- homeassistant/components/nam/sensor.py | 4 ++-- homeassistant/components/netatmo/device_trigger.py | 6 +++--- homeassistant/components/nobo_hub/__init__.py | 4 ++-- homeassistant/components/nuki/__init__.py | 6 +++--- homeassistant/components/owntracks/device_tracker.py | 4 ++-- homeassistant/components/plex/__init__.py | 12 +++++------- homeassistant/components/point/__init__.py | 6 ++---- homeassistant/components/ps4/__init__.py | 4 ++-- homeassistant/components/ps4/media_player.py | 6 +++--- 20 files changed, 62 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 0870e2234dc1..849a2055ce32 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -24,8 +24,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity, entity_registry -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -186,7 +185,9 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): def device_info(self) -> entity.DeviceInfo: """Return device info.""" return entity.DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address)}, + connections={ + (dr.CONNECTION_NETWORK_MAC, self._sensor_data.hub_mac_address) + }, manufacturer="IoTaWatt", model="IoTaWatt", ) @@ -196,7 +197,7 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): """Handle updated data from the coordinator.""" if self._key not in self.coordinator.data["sensors"]: if self._attr_unique_id: - entity_registry.async_get(self.hass).async_remove(self.entity_id) + er.async_get(self.hass).async_remove(self.entity_id) else: self.hass.async_create_task(self.async_remove()) return diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 8e46bf27d555..bfd1b820c7a6 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -43,7 +43,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle @@ -89,7 +89,7 @@ async def async_setup_entry( # Migrate old unique_id @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry): # Reject if new unique_id if entity_entry.unique_id.count(",") == 2: return None @@ -105,9 +105,7 @@ async def async_setup_entry( ) return {"new_unique_id": new_unique_id} - await entity_registry.async_migrate_entries( - hass, config_entry.entry_id, _async_migrator - ) + await er.async_migrate_entries(hass, config_entry.entry_id, _async_migrator) async_add_entities([IPMAWeather(location, api, config_entry.data)], True) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 68465c26c457..207c9e353a15 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -6,7 +6,7 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import ( CONF_CONSIDER_HOME, @@ -67,8 +67,8 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> _LOGGER.debug( "Cleaning device_tracker entities since some interfaces are now untracked:" ) - ent_reg = entity_registry.async_get(hass) - dev_reg = device_registry.async_get(hass) + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) # We keep devices currently connected to new_tracked_interfaces keep_devices: set[str] = { mac diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index fd4265a4ef09..c51d30431be8 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -40,7 +40,7 @@ async def async_setup_entry( update_from_router() - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) # Restore devices that are not a part of active clients list. restored = [] for entity_entry in registry.entities.values(): diff --git a/homeassistant/components/kodi/device_trigger.py b/homeassistant/components/kodi/device_trigger.py index 07fcf11c0771..c15c415bd9c3 100644 --- a/homeassistant/components/kodi/device_trigger.py +++ b/homeassistant/components/kodi/device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -33,11 +33,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Kodi devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain == "media_player": triggers.append( { diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 1ebc5ad6b80d..029eedb242d1 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -39,7 +39,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, - device_registry, + device_registry as dr, entity_platform, ) from homeassistant.helpers.entity import DeviceInfo @@ -407,7 +407,7 @@ class KodiEntity(MediaPlayerEntity): version = (await self._kodi.get_application_properties(["version"]))["version"] sw_version = f"{version['major']}.{version['minor']}" - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device({(DOMAIN, self.unique_id)}) dev_reg.async_update_device(device.id, sw_version=sw_version) diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index dc86fb73d9bb..0250f17052bc 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -55,11 +55,11 @@ async def async_setup_entry( @callback def async_update_sensors(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Add or remove sensors for configured tracked asset pairs.""" - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) existing_devices = { device.name: device.id - for device in device_registry.async_entries_for_config_entry( + for device in dr.async_entries_for_config_entry( dev_reg, config_entry.entry_id ) } @@ -125,7 +125,7 @@ class KrakenSensor( self._attr_device_info = DeviceInfo( configuration_url="https://www.kraken.com/", - entry_type=device_registry.DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DOMAIN, "_".join(self._device_name.split(" ")))}, manufacturer="Kraken.com", name=self._device_name, diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index 403627147f0d..c9adac23186c 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -24,8 +24,11 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import aiohttp_client, device_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -81,7 +84,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_handle_service_call(service_call: ServiceCall) -> None: """Handle a service call.""" # Get device entry from device registry - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) device_id = service_call.data["device_id"] device_entry = dev_reg.async_get(device_id) if TYPE_CHECKING: @@ -121,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def validate_mazda_device_id(device_id): """Check that a device ID exists in the registry and has at least one 'mazda' identifier.""" - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) if (device_entry := dev_reg.async_get(device_id)) is None: raise vol.Invalid("Invalid device ID") diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 057947d76e47..695c6c8f47d5 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -18,8 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry -from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -53,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def update_unique_id( - entity_entry: entity_registry.RegistryEntry, + entity_entry: er.RegistryEntry, ) -> dict[str, Any] | None: """Update unique ID of entity entry.""" @@ -86,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } return None - await entity_registry.async_migrate_entries(hass, entry.entry_id, update_unique_id) + await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) connection = datapoint.connection(api_key=api_key) @@ -154,7 +153,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def get_device_info(coordinates: str, name: str) -> DeviceInfo: """Return device registry information.""" return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinates)}, manufacturer="Met Office", name=f"Met Office {name}", diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 71d94a27fec4..14fbb83b61b9 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util @@ -31,7 +31,7 @@ async def async_setup_entry( tracked: dict[str, MikrotikDataUpdateCoordinatorTracker] = {} - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) # Restore clients that is not a part of active clients list. for entity in registry.entities.values(): diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index c011bdfa427a..73276017254f 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -21,9 +21,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -70,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Remove air_quality entities from registry if they exist - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for sensor_type in ("sds", ATTR_SDS011, ATTR_SPS30): unique_id = f"{coordinator.unique_id}-{sensor_type}" if entity_id := ent_reg.async_get_entity_id( @@ -130,7 +129,7 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]): def device_info(self) -> DeviceInfo: """Return the device info.""" return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, cast(str, self._unique_id))}, + connections={(dr.CONNECTION_NETWORK_MAC, cast(str, self._unique_id))}, name="Nettigo Air Monitor", sw_version=self.nam.software_version, manufacturer=MANUFACTURER, diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 878e9b9d9696..b78acbf32494 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -369,7 +369,7 @@ async def async_setup_entry( # Due to the change of the attribute name of two sensors, it is necessary to migrate # the unique_ids to the new names. - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for old_sensor, new_sensor in MIGRATION_SENSORS: old_unique_id = f"{coordinator.unique_id}-{old_sensor}" new_unique_id = f"{coordinator.unique_id}-{new_sensor}" diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index c6a519a37d0c..f3f45458d78b 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -20,7 +20,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import ( config_validation as cv, device_registry as dr, - entity_registry, + entity_registry as er, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -93,11 +93,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Netatmo devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) device_registry = dr.async_get(hass) triggers = [] - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if ( device := device_registry.async_get(device_id) ) is None or device.model is None: diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index d828fb78b783..bc2c328d647c 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from .const import ( ATTR_HARDWARE_VERSION, @@ -38,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) # Register hub as device - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, hub.hub_info[ATTR_SERIAL])}, diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 3a75c10333bd..f1c3d7149612 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -16,7 +16,7 @@ from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -78,7 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Device registration for the bridge info = bridge.info() bridge_id = parse_id(info["ids"]["hardwareId"]) - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, bridge_id)}, @@ -150,7 +150,7 @@ class NukiCoordinator(DataUpdateCoordinator[None]): except RequestException as err: raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - ent_reg = entity_registry.async_get(self.hass) + ent_reg = er.async_get(self.hass) for event, device_ids in events.items(): for device_id in device_ids: entity_id = ent_reg.async_get_entity_id( diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index f983d0f98d4e..a1fc632c2fd1 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -13,7 +13,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -26,7 +26,7 @@ async def async_setup_entry( ) -> None: """Set up OwnTracks based off an entry.""" # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index b215bc0d8216..78e8fac23a9b 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dev_reg, entity_registry as ent_reg +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import ( @@ -289,17 +289,15 @@ async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None @callback def async_cleanup_plex_devices(hass, entry): """Clean up old and invalid devices from the registry.""" - device_registry = dev_reg.async_get(hass) - entity_registry = ent_reg.async_get(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) - device_entries = dev_reg.async_entries_for_config_entry( - device_registry, entry.entry_id - ) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) for device_entry in device_entries: if ( len( - ent_reg.async_entries_for_device( + er.async_entries_for_device( entity_registry, device_entry.id, include_disabled_entities=True ) ) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index d4b837723003..6600a8240a0d 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -322,9 +322,7 @@ class MinutPointEntity(Entity): """Return a device description for device registry.""" device = self.device.device return DeviceInfo( - connections={ - (device_registry.CONNECTION_NETWORK_MAC, device["device_mac"]) - }, + connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, identifiers={(DOMAIN, device["device_id"])}, manufacturer="Minut", model=f"Point v{device['hardware_version']}", diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index d9c5f2f6ddb6..0f5c57c5e4cb 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall, split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType @@ -116,7 +116,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Migrate Version 2 -> Version 3: Update identifier format. if version == 2: # Prevent changing entity_id. Updates entity registry. - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) for entity_id, e_entry in registry.entities.items(): if e_entry.config_entry_id == entry.entry_id: diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 5df92fd795aa..3e6a15df340d 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -321,8 +321,8 @@ class PS4Device(MediaPlayerEntity): # If cannot get status on startup, assume info from registry. if status is None: _LOGGER.info("Assuming status from registry") - e_registry = entity_registry.async_get(self.hass) - d_registry = device_registry.async_get(self.hass) + e_registry = er.async_get(self.hass) + d_registry = dr.async_get(self.hass) for entity_id, entry in e_registry.entities.items(): if entry.config_entry_id == self._entry_id: self._attr_unique_id = entry.unique_id From 42b74e7f565876d1f5617856e4f44dce6534ebb1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 08:24:56 +0100 Subject: [PATCH 0147/1058] Adjust entity registry access in integrations (3) (#88948) --- homeassistant/components/amcrest/camera.py | 5 +-- .../components/arcam_fmj/device_trigger.py | 6 +-- homeassistant/components/coinbase/__init__.py | 9 ++-- .../components/coronavirus/__init__.py | 12 +++--- .../devolo_home_network/device_tracker.py | 8 ++-- .../components/dlna_dmr/config_flow.py | 5 +-- .../components/dlna_dmr/media_player.py | 12 +++--- homeassistant/components/enocean/switch.py | 5 +-- .../components/geofency/device_tracker.py | 4 +- homeassistant/components/glances/sensor.py | 4 +- .../components/gpslogger/device_tracker.py | 4 +- homeassistant/components/guardian/util.py | 4 +- homeassistant/components/harmony/__init__.py | 6 +-- homeassistant/components/homekit/__init__.py | 41 ++++++++++--------- .../components/homekit/config_flow.py | 13 +++--- .../components/homekit/type_triggers.py | 4 +- .../components/huawei_lte/__init__.py | 6 +-- .../components/huawei_lte/device_tracker.py | 4 +- homeassistant/components/hue/config_flow.py | 13 +++--- homeassistant/components/hue/v2/device.py | 14 +++---- homeassistant/components/hue/v2/hue_event.py | 4 +- 21 files changed, 90 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 9162d7841d1e..43201aba77a0 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -20,13 +20,12 @@ from homeassistant.components.camera import ( from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, async_get_clientsession, ) -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -146,7 +145,7 @@ async def async_setup_platform( # with this version, update the old entity with the new unique id. serial_number = await device.api.async_serial_number serial_number = serial_number.strip() - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) entity_id = registry.async_get_entity_id(CAMERA_DOMAIN, DOMAIN, serial_number) if entity_id is not None: _LOGGER.debug("Updating unique id for camera %s", entity_id) diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index f3722c81ec56..ecaec0e0e7df 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -32,11 +32,11 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for Arcam FMJ Receiver control devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain == "media_player": triggers.append( { diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index ecba1900b641..69d2bd9e9041 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -10,8 +10,7 @@ from coinbase.wallet.error import AuthenticationError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.util import Throttle from .const import ( @@ -71,10 +70,8 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non await hass.config_entries.async_reload(config_entry.entry_id) - registry = entity_registry.async_get(hass) - entities = entity_registry.async_entries_for_config_entry( - registry, config_entry.entry_id - ) + registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(registry, config_entry.entry_id) # Remove orphaned entities for entity in entities: diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py index a1c4f876f660..a3bc07ee0a1d 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -8,7 +8,11 @@ import coronavirus from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client, entity_registry, update_coordinator +from homeassistant.helpers import ( + aiohttp_client, + entity_registry as er, + update_coordinator, +) from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -31,16 +35,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry): """Migrate away from unstable ID.""" country, info_type = entity_entry.unique_id.rsplit("-", 1) if not country.isnumeric(): return None return {"new_unique_id": f"{entry.title}-{info_type}"} - await entity_registry.async_migrate_entries( - hass, entry.entry_id, _async_migrator - ) + await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"]) diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 79f2eb1f495b..eb6e9cf6ec6e 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -12,7 +12,7 @@ from homeassistant.components.device_tracker import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, UnitOfFrequency from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -30,7 +30,7 @@ async def async_setup_entry( coordinators: dict[ str, DataUpdateCoordinator[list[ConnectedStationInfo]] ] = hass.data[DOMAIN][entry.entry_id]["coordinators"] - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) tracked = set() @callback @@ -53,9 +53,7 @@ async def async_setup_entry( def restore_entities() -> None: """Restore clients that are not a part of active clients list.""" missing = [] - for entity in entity_registry.async_entries_for_config_entry( - registry, entry.entry_id - ): + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): if ( entity.platform == DOMAIN and entity.domain == DEVICE_TRACKER_DOMAIN diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 219f0497ff0e..bcd402e6a63e 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -21,8 +21,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_MAC, CONF_TYPE, from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import IntegrationError -from homeassistant.helpers import device_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( CONF_BROWSE_UNFILTERED, @@ -501,4 +500,4 @@ async def _async_get_mac_address(hass: HomeAssistant, host: str) -> str | None: if not mac_address: return None - return device_registry.format_mac(mac_address) + return dr.format_mac(mac_address) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 63bdb8fa6032..a866b911f391 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -29,7 +29,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -363,21 +363,21 @@ class DlnaDmrEntity(MediaPlayerEntity): # device's UDN. They may be the same, if the DMR is the root device. connections.add( ( - device_registry.CONNECTION_UPNP, + dr.CONNECTION_UPNP, self._device.profile_device.root_device.udn, ) ) - connections.add((device_registry.CONNECTION_UPNP, self._device.udn)) + connections.add((dr.CONNECTION_UPNP, self._device.udn)) if self.mac_address: # Connection based on MAC address, if known connections.add( # Device MAC is obtained from the config entry, which uses getmac - (device_registry.CONNECTION_NETWORK_MAC, self.mac_address) + (dr.CONNECTION_NETWORK_MAC, self.mac_address) ) # Create linked HA DeviceEntry now the information is known. - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) device_entry = dev_reg.async_get_or_create( config_entry_id=self.registry_entry.config_entry_id, connections=connections, @@ -388,7 +388,7 @@ class DlnaDmrEntity(MediaPlayerEntity): ) # Update entity registry to link to the device - ent_reg = entity_registry.async_get(self.hass) + ent_reg = er.async_get(self.hass) ent_reg.async_get_or_create( self.registry_entry.domain, self.registry_entry.platform, diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index 28727bfb7670..11ca8a2a625e 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -9,8 +9,7 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import CONF_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -38,7 +37,7 @@ def _migrate_to_new_unique_id(hass: HomeAssistant, dev_id, channel) -> None: """Migrate old unique ids to new unique ids.""" old_unique_id = f"{combine_hex(dev_id)}" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id(Platform.SWITCH, DOMAIN, old_unique_id) if entity_id is not None: diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index cc47883d05a8..892116121a0e 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -3,7 +3,7 @@ from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,7 +34,7 @@ async def async_setup_entry( ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index e0eaf3bb38a7..b8b5d80a2066 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -257,7 +257,7 @@ async def async_setup_entry( hass: HomeAssistant, old_unique_id: str, new_key: str ) -> None: """Migrate unique IDs to the new format.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) if entity_id := ent_reg.async_get_entity_id( Platform.SENSOR, DOMAIN, old_unique_id diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index a452d32e5441..317f2619beff 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -8,7 +8,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -44,7 +44,7 @@ async def async_setup_entry( ] = async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) # Restore previously loaded devices - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_ids = { identifier[1] for device in dev_reg.devices.values() diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 010f65cd114c..ff41c6e4936e 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -12,7 +12,7 @@ from aioguardian.errors import GuardianError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -41,7 +41,7 @@ def async_finish_entity_domain_replacements( entity_replacement_strategies: Iterable[EntityDomainReplacementStrategy], ) -> None: """Remove old entities and create a repairs issue with info on their replacement.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for strategy in entity_replacement_strategies: try: [registry_entry] = [ diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 259ea660317a..d861068629ff 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -5,7 +5,7 @@ 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.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -60,7 +60,7 @@ async def _migrate_old_unique_ids( names_to_ids = {activity["label"]: activity["id"] for activity in data.activities} @callback - def _async_migrator(entity_entry: entity_registry.RegistryEntry): + def _async_migrator(entity_entry: er.RegistryEntry): # Old format for switches was {remote_unique_id}-{activity_name} # New format is activity_{activity_id} parts = entity_entry.unique_id.split("-", 1) @@ -78,7 +78,7 @@ async def _migrate_old_unique_ids( return None - await entity_registry.async_migrate_entries(hass, entry_id, _async_migrator) + await er.async_migrate_entries(hass, entry_id, _async_migrator) @callback diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index c9faa2e28e0b..d5a6202ea271 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -50,8 +50,12 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import device_registry, entity_registry, instance_id -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + instance_id, +) from homeassistant.helpers.entityfilter import ( BASE_FILTER_SCHEMA, FILTER_SCHEMA, @@ -431,20 +435,19 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: async def async_handle_homekit_unpair(service: ServiceCall) -> None: """Handle unpair HomeKit service call.""" referenced = async_extract_referenced_entity_ids(hass, service) - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) for device_id in referenced.referenced_devices: if not (dev_reg_ent := dev_reg.async_get(device_id)): raise HomeAssistantError(f"No device found for device id: {device_id}") macs = [ cval for ctype, cval in dev_reg_ent.connections - if ctype == device_registry.CONNECTION_NETWORK_MAC + if ctype == dr.CONNECTION_NETWORK_MAC ] matching_instances = [ homekit for homekit in _async_all_homekit_instances(hass) - if homekit.driver - and device_registry.format_mac(homekit.driver.state.mac) in macs + if homekit.driver and dr.format_mac(homekit.driver.state.mac) in macs ] if not matching_instances: raise HomeAssistantError( @@ -698,7 +701,7 @@ class HomeKit: return False def add_bridge_triggers_accessory( - self, device: device_registry.DeviceEntry, device_triggers: list[dict[str, Any]] + self, device: dr.DeviceEntry, device_triggers: list[dict[str, Any]] ) -> None: """Add device automation triggers to the bridge.""" if self._would_exceed_max_devices(device.name): @@ -734,8 +737,8 @@ class HomeKit: async def async_configure_accessories(self) -> list[State]: """Configure accessories for the included states.""" - dev_reg = device_registry.async_get(self.hass) - ent_reg = entity_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) + ent_reg = er.async_get(self.hass) device_lookup = ent_reg.async_get_device_class_lookup( { (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.BATTERY_CHARGING), @@ -830,8 +833,8 @@ class HomeKit: def _async_register_bridge(self) -> None: """Register the bridge as a device so homekit_controller and exclude it from discovery.""" assert self.driver is not None - dev_reg = device_registry.async_get(self.hass) - formatted_mac = device_registry.format_mac(self.driver.state.mac) + dev_reg = dr.async_get(self.hass) + formatted_mac = dr.format_mac(self.driver.state.mac) # Connections and identifiers are both used here. # # connections exists so homekit_controller can know the @@ -844,7 +847,7 @@ class HomeKit: # because this was the way you had to fix homekit when pairing # failed. # - connection = (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) + connection = (dr.CONNECTION_NETWORK_MAC, formatted_mac) identifier = (DOMAIN, self._entry_id, BRIDGE_SERIAL_NUMBER) self._async_purge_old_bridges(dev_reg, identifier, connection) is_accessory_mode = self._homekit_mode == HOMEKIT_MODE_ACCESSORY @@ -858,13 +861,13 @@ class HomeKit: manufacturer=MANUFACTURER, name=accessory_friendly_name(self._entry_title, self.driver.accessory), model=f"HomeKit {hk_mode_name}", - entry_type=device_registry.DeviceEntryType.SERVICE, + entry_type=dr.DeviceEntryType.SERVICE, ) @callback def _async_purge_old_bridges( self, - dev_reg: device_registry.DeviceRegistry, + dev_reg: dr.DeviceRegistry, identifier: tuple[str, str, str], connection: tuple[str, str], ) -> None: @@ -920,7 +923,7 @@ class HomeKit: async def _async_add_trigger_accessories(self) -> None: """Add devices with triggers to the bridge.""" - dev_reg = device_registry.async_get(self.hass) + dev_reg = dr.async_get(self.hass) valid_device_ids = [] for device_id in self._devices: if not dev_reg.async_get(device_id): @@ -989,7 +992,7 @@ class HomeKit: @callback def _async_configure_linked_sensors( self, - ent_reg_ent: entity_registry.RegistryEntry, + ent_reg_ent: er.RegistryEntry, device_lookup: dict[str, dict[tuple[str, str | None], str]], state: State, ) -> None: @@ -1051,8 +1054,8 @@ class HomeKit: async def _async_set_device_info_attributes( self, - ent_reg_ent: entity_registry.RegistryEntry, - dev_reg: device_registry.DeviceRegistry, + ent_reg_ent: er.RegistryEntry, + dev_reg: dr.DeviceRegistry, entity_id: str, ) -> None: """Set attributes that will be used for homekit device info.""" @@ -1070,7 +1073,7 @@ class HomeKit: ent_cfg[ATTR_INTEGRATION] = ent_reg_ent.platform def _fill_config_from_device_registry_entry( - self, device_entry: device_registry.DeviceEntry, config: dict[str, Any] + self, device_entry: dr.DeviceEntry, config: dict[str, Any] ) -> None: """Populate a config dict from the registry.""" if device_entry.manufacturer: diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index dddce5eae325..3747af3edc7d 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -28,8 +28,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import device_registry, entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, CONF_EXCLUDE_ENTITIES, @@ -630,7 +633,7 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: results = await device_automation.async_get_device_automations( hass, device_automation.DeviceAutomationType.TRIGGER ) - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) unsorted: dict[str, str] = {} for device_id in results: entry = dev_reg.async_get(device_id) @@ -639,7 +642,7 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: def _exclude_by_entity_registry( - ent_reg: entity_registry.EntityRegistry, + ent_reg: er.EntityRegistry, entity_id: str, include_entity_category: bool, include_hidden: bool, @@ -661,7 +664,7 @@ def _async_get_matching_entities( include_hidden: bool = False, ) -> dict[str, str]: """Fetch all entities or entities in the given domains.""" - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) return { state.entity_id: ( f"{state.attributes.get(ATTR_FRIENDLY_NAME, state.entity_id)} ({state.entity_id})" diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index b239d67877c7..eb2cd5d34ad4 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -7,7 +7,7 @@ from typing import Any from pyhap.const import CATEGORY_SENSOR from homeassistant.core import CALLBACK_TYPE, Context -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.trigger import async_initialize_triggers from .accessories import TYPES, HomeAccessory @@ -42,7 +42,7 @@ class DeviceTriggerAccessory(HomeAccessory): self._remove_triggers: CALLBACK_TYPE | None = None self.triggers = [] assert device_triggers is not None - ent_reg = entity_registry.async_get(self.hass) + ent_reg = er.async_get(self.hass) for idx, trigger in enumerate(device_triggers): type_: str = trigger["type"] subtype: str | None = trigger.get("subtype") diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 0f661498713c..5e5b2c8dc946 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -44,7 +44,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery, - entity_registry, + entity_registry as er, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -359,8 +359,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Transitional from < 2021.8: update None config entry and entity unique ids if router_info and (serial_number := router_info.get("SerialNumber")): hass.config_entries.async_update_entry(entry, unique_id=serial_number) - ent_reg = entity_registry.async_get(hass) - for entity_entry in entity_registry.async_entries_for_config_entry( + ent_reg = er.async_get(hass) + for entity_entry in er.async_entries_for_config_entry( ent_reg, entry.entry_id ): if not entity_entry.unique_id.startswith("None-"): diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 52d12d200058..b8833b24d921 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -15,7 +15,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import 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 @@ -66,7 +66,7 @@ async def async_setup_entry( # Initialize already tracked entities tracked: set[str] = set() - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) known_entities: list[Entity] = [] track_wired_clients = router.config_entry.options.get( CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index d87da5b5ac09..cd2553353b3e 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -18,8 +18,11 @@ from homeassistant.components import zeroconf from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client, device_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) from homeassistant.util.network import is_ipv6_address from .const import ( @@ -306,10 +309,8 @@ class HueV2OptionsFlowHandler(config_entries.OptionsFlow): # create a list of Hue device ID's that the user can select # to ignore availability status - dev_reg = device_registry.async_get(self.hass) - entries = device_registry.async_entries_for_config_entry( - dev_reg, self.config_entry.entry_id - ) + dev_reg = dr.async_get(self.hass) + entries = dr.async_entries_for_config_entry(dev_reg, self.config_entry.entry_id) dev_ids = { identifier[1]: entry.name for entry in entries diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index c3deee40023e..bc3ce49cb6b4 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ATTR_VIA_DEVICE, ) from homeassistant.core import callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from ..const import DOMAIN @@ -29,11 +29,11 @@ async def async_setup_devices(bridge: "HueBridge"): entry = bridge.config_entry hass = bridge.hass api: HueBridgeV2 = bridge.api # to satisfy typing - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) dev_controller = api.devices @callback - def add_device(hue_device: Device) -> device_registry.DeviceEntry: + def add_device(hue_device: Device) -> dr.DeviceEntry: """Register a Hue device in device registry.""" model = f"{hue_device.product_data.product_name} ({hue_device.product_data.model_id})" params = { @@ -51,9 +51,7 @@ async def async_setup_devices(bridge: "HueBridge"): params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) zigbee = dev_controller.get_zigbee_connectivity(hue_device.id) if zigbee and zigbee.mac_address: - params[ATTR_CONNECTIONS] = { - (device_registry.CONNECTION_NETWORK_MAC, zigbee.mac_address) - } + params[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, zigbee.mac_address)} return dev_reg.async_get_or_create(config_entry_id=entry.entry_id, **params) @@ -77,9 +75,7 @@ async def async_setup_devices(bridge: "HueBridge"): known_devices = [add_device(hue_device) for hue_device in dev_controller] # Check for nodes that no longer exist and remove them - for device in device_registry.async_entries_for_config_entry( - dev_reg, entry.entry_id - ): + for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): if device not in known_devices: # handle case where a virtual device was created for a Hue group hue_dev_id = next(x[1] for x in device.identifiers if x[0] == DOMAIN) diff --git a/homeassistant/components/hue/v2/hue_event.py b/homeassistant/components/hue/v2/hue_event.py index 07a54e0f84f2..e0296bcb4346 100644 --- a/homeassistant/components/hue/v2/hue_event.py +++ b/homeassistant/components/hue/v2/hue_event.py @@ -9,7 +9,7 @@ from aiohue.v2.models.relative_rotary import RelativeRotary from homeassistant.const import CONF_DEVICE_ID, CONF_ID, CONF_TYPE, CONF_UNIQUE_ID from homeassistant.core import callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.util import slugify from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN @@ -29,7 +29,7 @@ async def async_setup_hue_events(bridge: "HueBridge"): hass = bridge.hass api: HueBridgeV2 = bridge.api # to satisfy typing conf_entry = bridge.config_entry - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) btn_controller = api.sensors.button rotary_controller = api.sensors.relative_rotary From 9ab95b6348e79b2f0529994f13228c25002a77b2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 08:53:05 +0100 Subject: [PATCH 0148/1058] Revert "Add `state_class = MEASUREMENT` to Derivative sensor (#88408)" (#88952) --- homeassistant/components/derivative/sensor.py | 7 +------ tests/components/derivative/test_sensor.py | 2 -- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index a8cacc0e20d0..adf91eb706b6 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -8,11 +8,7 @@ from typing import TYPE_CHECKING import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorStateClass, -) +from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -135,7 +131,6 @@ class DerivativeSensor(RestoreEntity, SensorEntity): _attr_icon = ICON _attr_should_poll = False - _attr_state_class = SensorStateClass.MEASUREMENT def __init__( self, diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 9c2f68fd6859..c1541812d1b5 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -4,7 +4,6 @@ from math import sin import random from unittest.mock import patch -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -79,7 +78,6 @@ async def setup_tests(hass, config, times, values, expected_state): assert state is not None assert round(float(state.state), config["sensor"]["round"]) == expected_state - assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT return state From 202bed5d51fa5931cde153446c1b0acc3e6fe3e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Mar 2023 02:07:46 -0600 Subject: [PATCH 0149/1058] Fix lingering reload task in notion reauth (#88949) Co-authored-by: Martin Hjelmare --- tests/components/notion/test_config_flow.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index de5c871ce536..d0d1cad0350c 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -109,6 +109,11 @@ async def test_reauth( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password"} ) + # Block to ensure the setup_config_entry fixture does not + # get undone before hass is shutdown so we do not try + # to setup the config entry via reload. + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert len(hass.config_entries.async_entries()) == 1 From 853bd52a22ee254d6fc3ac1d6a6264678d5b4732 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 09:11:14 +0100 Subject: [PATCH 0150/1058] Adjust entity registry access in tests (1) (#88950) --- .../components/aladdin_connect/test_sensor.py | 32 +++++++++---------- .../components/broadlink/test_config_flow.py | 6 ++-- tests/components/coinbase/test_init.py | 25 ++++++++------- .../command_line/test_binary_sensor.py | 22 ++++++------- tests/components/command_line/test_cover.py | 17 +++++----- tests/components/command_line/test_sensor.py | 17 +++++----- tests/components/command_line/test_switch.py | 17 +++++----- .../devolo_home_control/test_binary_sensor.py | 9 +++--- .../devolo_home_control/test_sensor.py | 9 +++--- .../devolo_home_network/test_binary_sensor.py | 11 ++++--- .../test_device_tracker.py | 16 ++++++---- .../devolo_home_network/test_sensor.py | 18 +++++++---- .../devolo_home_network/test_switch.py | 9 +++--- tests/components/dlna_dmr/test_init.py | 7 ++-- tests/components/flipr/test_binary_sensor.py | 8 ++--- tests/components/flipr/test_sensor.py | 16 ++++------ tests/components/generic/test_config_flow.py | 12 +++---- tests/components/harmony/test_switch.py | 22 +++++++------ tests/components/hassio/test_binary_sensor.py | 11 ++++--- tests/components/hassio/test_sensor.py | 11 ++++--- 20 files changed, 153 insertions(+), 142 deletions(-) diff --git a/tests/components/aladdin_connect/test_sensor.py b/tests/components/aladdin_connect/test_sensor.py index 282f6d3e04c0..c01d6c5c7811 100644 --- a/tests/components/aladdin_connect/test_sensor.py +++ b/tests/components/aladdin_connect/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -28,6 +28,7 @@ RELOAD_AFTER_UPDATE_DELAY = timedelta(seconds=31) async def test_sensors( hass: HomeAssistant, mock_aladdinconnect_api: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test Sensors for AladdinConnect.""" config_entry = MockConfigEntry( @@ -46,12 +47,11 @@ async def test_sensors( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entry = registry.async_get("sensor.home_battery_level") + entry = entity_registry.async_get("sensor.home_battery_level") assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION - update_entry = registry.async_update_entity( + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -68,12 +68,12 @@ async def test_sensors( state = hass.states.get("sensor.home_battery_level") assert state - entry = registry.async_get("sensor.home_wi_fi_rssi") + entry = entity_registry.async_get("sensor.home_wi_fi_rssi") await hass.async_block_till_done() assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION - update_entry = registry.async_update_entity( + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -82,7 +82,7 @@ async def test_sensors( state = hass.states.get("sensor.home_wi_fi_rssi") assert state is None - update_entry = registry.async_update_entity( + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -99,6 +99,7 @@ async def test_sensors( async def test_sensors_model_01( hass: HomeAssistant, mock_aladdinconnect_api: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test Sensors for AladdinConnect.""" config_entry = MockConfigEntry( @@ -120,20 +121,19 @@ async def test_sensors_model_01( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entry = registry.async_get("sensor.home_battery_level") + entry = entity_registry.async_get("sensor.home_battery_level") assert entry assert entry.disabled is False assert entry.disabled_by is None state = hass.states.get("sensor.home_battery_level") assert state - entry = registry.async_get("sensor.home_wi_fi_rssi") + entry = entity_registry.async_get("sensor.home_wi_fi_rssi") await hass.async_block_till_done() assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION - update_entry = registry.async_update_entity( + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -142,7 +142,7 @@ async def test_sensors_model_01( state = hass.states.get("sensor.home_wi_fi_rssi") assert state is None - update_entry = registry.async_update_entity( + update_entry = entity_registry.async_update_entity( entry.entity_id, **{"disabled_by": None} ) await hass.async_block_till_done() @@ -155,7 +155,7 @@ async def test_sensors_model_01( state = hass.states.get("sensor.home_wi_fi_rssi") assert state - entry = registry.async_get("sensor.home_ble_strength") + entry = entity_registry.async_get("sensor.home_ble_strength") await hass.async_block_till_done() assert entry assert entry.disabled is False diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py index 63af7a70c726..314e017beded 100644 --- a/tests/components/broadlink/test_config_flow.py +++ b/tests/components/broadlink/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.broadlink.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from . import get_device @@ -838,7 +838,7 @@ async def test_dhcp_can_finish(hass: HomeAssistant) -> None: data=dhcp.DhcpServiceInfo( hostname="broadlink", ip="1.2.3.4", - macaddress=device_registry.format_mac(device.mac), + macaddress=dr.format_mac(device.mac), ), ) await hass.async_block_till_done() @@ -932,7 +932,7 @@ async def test_dhcp_device_not_supported(hass: HomeAssistant) -> None: data=dhcp.DhcpServiceInfo( hostname="broadlink", ip=device.host, - macaddress=device_registry.format_mac(device.mac), + macaddress=dr.format_mac(device.mac), ), ) diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index 60449570d225..c518c71098db 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components.coinbase.const import ( DOMAIN, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .common import ( init_mock_coinbase, @@ -49,7 +49,9 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert not hass.data.get(DOMAIN) -async def test_option_updates(hass: HomeAssistant) -> None: +async def test_option_updates( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test handling option updates.""" with patch( @@ -75,9 +77,8 @@ async def test_option_updates(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entities = entity_registry.async_entries_for_config_entry( - registry, config_entry.entry_id + entities = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) assert len(entities) == 4 currencies = [ @@ -106,9 +107,8 @@ async def test_option_updates(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entities = entity_registry.async_entries_for_config_entry( - registry, config_entry.entry_id + entities = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) assert len(entities) == 2 currencies = [ @@ -127,7 +127,9 @@ async def test_option_updates(hass: HomeAssistant) -> None: assert rates == [GOOD_EXCHANGE_RATE] -async def test_ignore_vaults_wallets(hass: HomeAssistant) -> None: +async def test_ignore_vaults_wallets( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test vaults are ignored in wallet sensors.""" with patch( @@ -142,9 +144,8 @@ async def test_ignore_vaults_wallets(hass: HomeAssistant) -> None: config_entry = await init_mock_coinbase(hass, currencies=[GOOD_CURRENCY]) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entities = entity_registry.async_entries_for_config_entry( - registry, config_entry.entry_id + entities = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id ) assert len(entities) == 1 entity = entities[0] diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 3f70673849a8..a6486b40040e 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant import setup from homeassistant.components.binary_sensor import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: @@ -72,7 +72,9 @@ async def test_sensor_off(hass: HomeAssistant) -> None: assert entity_state.state == STATE_OFF -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option and if it only creates one binary sensor per id.""" assert await setup.async_setup_component( hass, @@ -101,18 +103,12 @@ async def test_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ( - ent_reg.async_get_entity_id("binary_sensor", "command_line", "unique") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id( + "binary_sensor", "command_line", "unique" ) - assert ( - ent_reg.async_get_entity_id( - "binary_sensor", "command_line", "not-so-unique-anymore" - ) - is not None + assert entity_registry.async_get_entity_id( + "binary_sensor", "command_line", "not-so-unique-anymore" ) diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 220da18409ca..bfb74832f907 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -18,7 +18,7 @@ from homeassistant.const import ( SERVICE_STOP_COVER, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path @@ -171,7 +171,9 @@ async def test_move_cover_failure( assert "return code 1" in caplog.text -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option and if it only creates one cover per id.""" await setup_test_entity( hass, @@ -199,11 +201,8 @@ async def test_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ent_reg.async_get_entity_id("cover", "command_line", "unique") is not None - assert ( - ent_reg.async_get_entity_id("cover", "command_line", "not-so-unique-anymore") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id("cover", "command_line", "unique") + assert entity_registry.async_get_entity_id( + "cover", "command_line", "not-so-unique-anymore" ) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index c67e97ef81a2..347c6a7ffda8 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant import setup from homeassistant.components.sensor import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er async def setup_test_entities(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: @@ -260,7 +260,9 @@ async def test_update_with_unnecessary_json_attrs( assert "key_three" not in entity_state.attributes -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option and if it only creates one sensor per id.""" assert await setup.async_setup_component( hass, @@ -289,11 +291,8 @@ async def test_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ent_reg.async_get_entity_id("sensor", "command_line", "unique") is not None - assert ( - ent_reg.async_get_entity_id("sensor", "command_line", "not-so-unique-anymore") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id("sensor", "command_line", "unique") + assert entity_registry.async_get_entity_id( + "sensor", "command_line", "not-so-unique-anymore" ) diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index c1ff567f15e8..ac1ae3571230 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed @@ -393,7 +393,9 @@ async def test_no_switches( assert "No switches" in caplog.text -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option and if it only creates one switch per id.""" await setup_test_entity( hass, @@ -418,13 +420,10 @@ async def test_unique_id(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ent_reg.async_get_entity_id("switch", "command_line", "unique") is not None - assert ( - ent_reg.async_get_entity_id("switch", "command_line", "not-so-unique-anymore") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id("switch", "command_line", "unique") + assert entity_registry.async_get_entity_id( + "switch", "command_line", "not-so-unique-anymore" ) diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index fa132158a64b..1fa2248c7171 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import ( @@ -24,7 +24,9 @@ from .mocks import ( @pytest.mark.usefixtures("mock_zeroconf") -async def test_binary_sensor(hass: HomeAssistant) -> None: +async def test_binary_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test setup and state change of a binary sensor device.""" entry = configure_integration(hass) test_gateway = HomeControlMockBinarySensor() @@ -44,9 +46,8 @@ async def test_binary_sensor(hass: HomeAssistant) -> None: state = hass.states.get(f"{DOMAIN}.test_overload") assert state is not None assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Overload" - er = entity_registry.async_get(hass) assert ( - er.async_get(f"{DOMAIN}.test_overload").entity_category + entity_registry.async_get(f"{DOMAIN}.test_overload").entity_category == EntityCategory.DIAGNOSTIC ) diff --git a/tests/components/devolo_home_control/test_sensor.py b/tests/components/devolo_home_control/test_sensor.py index 35ff0358ca72..9746fca6b6f8 100644 --- a/tests/components/devolo_home_control/test_sensor.py +++ b/tests/components/devolo_home_control/test_sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockConsumption, HomeControlMockSensor @@ -43,10 +43,11 @@ async def test_temperature_sensor(hass: HomeAssistant) -> None: assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE -async def test_battery_sensor(hass: HomeAssistant) -> None: +async def test_battery_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test setup and state change of a battery sensor device.""" entry = configure_integration(hass) - er = entity_registry.async_get(hass) test_gateway = HomeControlMockSensor() test_gateway.devices["Test"].battery_level = 25 with patch( @@ -63,7 +64,7 @@ async def test_battery_sensor(hass: HomeAssistant) -> None: assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY assert ( - er.async_get(f"{DOMAIN}.test_battery_level").entity_category + entity_registry.async_get(f"{DOMAIN}.test_battery_level").entity_category is EntityCategory.DIAGNOSTIC ) diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 6c76d775ec84..5906112ffd14 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt from . import configure_integration @@ -42,15 +42,13 @@ async def test_binary_sensor_setup(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_attached_to_router( - hass: HomeAssistant, mock_device: MockDevice + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry ) -> None: """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() state_key = f"{DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}" - er = entity_registry.async_get(hass) - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -59,7 +57,10 @@ async def test_update_attached_to_router( assert state.state == STATE_OFF assert state.attributes[ATTR_FRIENDLY_NAME] == f"{entry.title} Connected to router" - assert er.async_get(state_key).entity_category == EntityCategory.DIAGNOSTIC + assert ( + entity_registry.async_get(state_key).entity_category + == EntityCategory.DIAGNOSTIC + ) # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index ffcfb71d311f..963956abd905 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UnitOfFrequency, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt from . import configure_integration @@ -30,20 +30,21 @@ STATION = CONNECTED_STATIONS[0] SERIAL = DISCOVERY_INFO.properties["SN"] -async def test_device_tracker(hass: HomeAssistant, mock_device: MockDevice) -> None: +async def test_device_tracker( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: """Test device tracker states.""" state_key = ( f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" ) entry = configure_integration(hass) - er = entity_registry.async_get(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() # Enable entity - er.async_update_entity(state_key, disabled_by=None) + entity_registry.async_update_entity(state_key, disabled_by=None) await hass.async_block_till_done() async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() @@ -82,14 +83,15 @@ async def test_device_tracker(hass: HomeAssistant, mock_device: MockDevice) -> N await hass.config_entries.async_unload(entry.entry_id) -async def test_restoring_clients(hass: HomeAssistant, mock_device: MockDevice) -> None: +async def test_restoring_clients( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: """Test restoring existing device_tracker entities.""" state_key = ( f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" ) entry = configure_integration(hass) - er = entity_registry.async_get(hass) - er.async_get_or_create( + entity_registry.async_get_or_create( PLATFORM, DOMAIN, f"{SERIAL}_{STATION.mac_address}", diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index ee4c5f782c1e..fc8afbe1ae8f 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.devolo_home_network.const import ( from homeassistant.components.sensor import DOMAIN, SensorStateClass from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt from . import configure_integration @@ -78,13 +78,12 @@ async def test_update_connected_wifi_clients( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_neighboring_wifi_networks( - hass: HomeAssistant, mock_device: MockDevice + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry ) -> None: """Test state change of a neighboring_wifi_networks sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() state_key = f"{DOMAIN}.{device_name}_neighboring_wifi_networks" - er = entity_registry.async_get(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -95,7 +94,10 @@ async def test_update_neighboring_wifi_networks( state.attributes[ATTR_FRIENDLY_NAME] == f"{entry.title} Neighboring Wifi networks" ) - assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC + assert ( + entity_registry.async_get(state_key).entity_category + is EntityCategory.DIAGNOSTIC + ) # Emulate device failure mock_device.device.async_get_wifi_neighbor_access_points = AsyncMock( @@ -122,13 +124,12 @@ async def test_update_neighboring_wifi_networks( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_connected_plc_devices( - hass: HomeAssistant, mock_device: MockDevice + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry ) -> None: """Test state change of a connected_plc_devices sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() state_key = f"{DOMAIN}.{device_name}_connected_plc_devices" - er = entity_registry.async_get(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -138,7 +139,10 @@ async def test_update_connected_plc_devices( assert ( state.attributes[ATTR_FRIENDLY_NAME] == f"{entry.title} Connected PLC devices" ) - assert er.async_get(state_key).entity_category is EntityCategory.DIAGNOSTIC + assert ( + entity_registry.async_get(state_key).entity_category + is EntityCategory.DIAGNOSTIC + ) # Emulate device failure mock_device.plcnet.async_get_network_overview = AsyncMock( diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index dfe2b1176c02..257ccfbb6e31 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -21,7 +21,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN from homeassistant.util import dt @@ -157,7 +157,9 @@ async def test_update_enable_guest_wifi( await hass.config_entries.async_unload(entry.entry_id) -async def test_update_enable_leds(hass: HomeAssistant, mock_device: MockDevice) -> None: +async def test_update_enable_leds( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: """Test state change of a enable_leds switch device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() @@ -170,8 +172,7 @@ async def test_update_enable_leds(hass: HomeAssistant, mock_device: MockDevice) assert state is not None assert state.state == STATE_OFF - er = entity_registry.async_get(hass) - assert er.async_get(state_key).entity_category == EntityCategory.CONFIG + assert entity_registry.async_get(state_key).entity_category == EntityCategory.CONFIG # Emulate state change mock_device.device.async_get_led_setting.return_value = True diff --git a/tests/components/dlna_dmr/test_init.py b/tests/components/dlna_dmr/test_init.py index be793d67c5e5..f1c3151fb281 100644 --- a/tests/components/dlna_dmr/test_init.py +++ b/tests/components/dlna_dmr/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import Mock from homeassistant.components import media_player from homeassistant.components.dlna_dmr.const import DOMAIN as DLNA_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -17,6 +17,7 @@ async def test_resource_lifecycle( config_entry_mock: MockConfigEntry, ssdp_scanner_mock: Mock, dmr_device_mock: Mock, + entity_registry: er.EntityRegistry, ) -> None: """Test that resources are acquired/released as the entity is setup/unloaded.""" # Set up the config entry @@ -25,8 +26,8 @@ async def test_resource_lifecycle( await hass.async_block_till_done() # Check the entity is created and working - entries = entity_registry.async_entries_for_config_entry( - entity_registry.async_get(hass), config_entry_mock.entry_id + entries = er.async_entries_for_config_entry( + entity_registry, config_entry_mock.entry_id ) assert len(entries) == 1 entity_id = entries[0].entity_id diff --git a/tests/components/flipr/test_binary_sensor.py b/tests/components/flipr/test_binary_sensor.py index fc24ddee3405..fa938521d3bd 100644 --- a/tests/components/flipr/test_binary_sensor.py +++ b/tests/components/flipr/test_binary_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as entity_reg +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -23,7 +23,7 @@ MOCK_FLIPR_MEASURE = { } -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the creation and values of the Flipr binary sensors.""" entry = MockConfigEntry( domain=DOMAIN, @@ -37,8 +37,6 @@ async def test_sensors(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - registry = entity_reg.async_get(hass) - with patch( "flipr_api.FliprAPIRestClient.get_pool_measure_latest", return_value=MOCK_FLIPR_MEASURE, @@ -47,7 +45,7 @@ async def test_sensors(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Check entity unique_id value that is generated in FliprEntity base class. - entity = registry.async_get("binary_sensor.flipr_myfliprid_ph_status") + entity = entity_registry.async_get("binary_sensor.flipr_myfliprid_ph_status") assert entity.unique_id == "myfliprid-ph_status" state = hass.states.get("binary_sensor.flipr_myfliprid_ph_status") diff --git a/tests/components/flipr/test_sensor.py b/tests/components/flipr/test_sensor.py index 75ab6ffd0bda..cd31ec33c124 100644 --- a/tests/components/flipr/test_sensor.py +++ b/tests/components/flipr/test_sensor.py @@ -15,7 +15,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as entity_reg +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ MOCK_FLIPR_MEASURE = { } -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test the creation and values of the Flipr sensors.""" entry = MockConfigEntry( domain=DOMAIN, @@ -48,8 +48,6 @@ async def test_sensors(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - registry = entity_reg.async_get(hass) - with patch( "flipr_api.FliprAPIRestClient.get_pool_measure_latest", return_value=MOCK_FLIPR_MEASURE, @@ -58,7 +56,7 @@ async def test_sensors(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Check entity unique_id value that is generated in FliprEntity base class. - entity = registry.async_get("sensor.flipr_myfliprid_red_ox") + entity = entity_registry.async_get("sensor.flipr_myfliprid_red_ox") assert entity.unique_id == "myfliprid-red_ox" state = hass.states.get("sensor.flipr_myfliprid_ph") @@ -104,7 +102,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "95.0" -async def test_error_flipr_api_sensors(hass: HomeAssistant) -> None: +async def test_error_flipr_api_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the Flipr sensors error.""" entry = MockConfigEntry( domain=DOMAIN, @@ -118,8 +118,6 @@ async def test_error_flipr_api_sensors(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - registry = entity_reg.async_get(hass) - with patch( "flipr_api.FliprAPIRestClient.get_pool_measure_latest", side_effect=FliprError("Error during flipr data retrieval..."), @@ -128,5 +126,5 @@ async def test_error_flipr_api_sensors(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Check entity is not generated because of the FliprError raised. - entity = registry.async_get("sensor.flipr_myfliprid_red_ox") + entity = entity_registry.async_get("sensor.flipr_myfliprid_red_ox") assert entity is None diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index a4fdf92895e8..881734997523 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -34,7 +34,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -809,11 +809,11 @@ async def test_reload_on_title_change(hass: HomeAssistant) -> None: assert hass.states.get("camera.my_title").attributes["friendly_name"] == "New Title" -async def test_migrate_existing_ids(hass: HomeAssistant) -> None: +async def test_migrate_existing_ids( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test that existing ids are migrated for issue #70568.""" - registry = entity_registry.async_get(hass) - test_data = TESTDATA_OPTIONS.copy() test_data[CONF_CONTENT_TYPE] = "image/png" old_unique_id = "54321" @@ -825,7 +825,7 @@ async def test_migrate_existing_ids(hass: HomeAssistant) -> None: new_unique_id = mock_entry.entry_id mock_entry.add_to_hass(hass) - entity_entry = registry.async_get_or_create( + entity_entry = entity_registry.async_get_or_create( "camera", DOMAIN, old_unique_id, @@ -838,7 +838,7 @@ async def test_migrate_existing_ids(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - entity_entry = registry.async_get(entity_id) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry.unique_id == new_unique_id diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index ee276fdec91d..58cbd3eac560 100644 --- a/tests/components/harmony/test_switch.py +++ b/tests/components/harmony/test_switch.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util import utcnow from .const import ENTITY_PLAY_MUSIC, ENTITY_REMOTE, ENTITY_WATCH_TV, HUB_NAME @@ -25,7 +25,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_connection_state_changes( - harmony_client, mock_hc, hass: HomeAssistant, mock_write_config + harmony_client, + mock_hc, + hass: HomeAssistant, + mock_write_config, + entity_registry: er.EntityRegistry, ) -> None: """Ensure connection changes are reflected in the switch states.""" entry = MockConfigEntry( @@ -41,9 +45,8 @@ async def test_connection_state_changes( assert not hass.states.get(ENTITY_PLAY_MUSIC) # enable switch entities - ent_reg = entity_registry.async_get(hass) - ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) - ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) + entity_registry.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) + entity_registry.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -80,7 +83,9 @@ async def test_connection_state_changes( assert hass.states.is_state(ENTITY_PLAY_MUSIC, STATE_OFF) -async def test_switch_toggles(mock_hc, hass: HomeAssistant, mock_write_config) -> None: +async def test_switch_toggles( + mock_hc, hass: HomeAssistant, mock_write_config, entity_registry: er.EntityRegistry +) -> None: """Ensure calls to the switch modify the harmony state.""" entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "192.0.2.0", CONF_NAME: HUB_NAME} @@ -91,9 +96,8 @@ async def test_switch_toggles(mock_hc, hass: HomeAssistant, mock_write_config) - await hass.async_block_till_done() # enable switch entities - ent_reg = entity_registry.async_get(hass) - ent_reg.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) - ent_reg.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) + entity_registry.async_update_entity(ENTITY_WATCH_TV, disabled_by=None) + entity_registry.async_update_entity(ENTITY_PLAY_MUSIC, disabled_by=None) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index a172771cee81..133074d7c9de 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.hassio import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -157,7 +157,11 @@ def mock_all(aioclient_mock, request): ], ) async def test_binary_sensor( - hass: HomeAssistant, entity_id, expected, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_id, + expected, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test hassio OS and addons binary sensor.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -176,8 +180,7 @@ async def test_binary_sensor( assert hass.states.get(entity_id) is None # Enable the entity. - ent_reg = entity_registry.async_get(hass) - ent_reg.async_update_entity(entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 225824e535d7..4088ba631f49 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.hassio import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -158,7 +158,11 @@ def mock_all(aioclient_mock, request): ], ) async def test_sensor( - hass: HomeAssistant, entity_id, expected, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_id, + expected, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, ) -> None: """Test hassio OS and addons sensor.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -177,8 +181,7 @@ async def test_sensor( assert hass.states.get(entity_id) is None # Enable the entity. - ent_reg = entity_registry.async_get(hass) - ent_reg.async_update_entity(entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() From 1fa3f324745053dce0bbfc29e6b090618c7f8515 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 09:41:55 +0100 Subject: [PATCH 0151/1058] Add missing mock in notion tests (#88951) --- tests/components/notion/conftest.py | 10 ++++++++++ tests/components/notion/test_config_flow.py | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/components/notion/conftest.py b/tests/components/notion/conftest.py index a66d99d41cef..7484e8a997fb 100644 --- a/tests/components/notion/conftest.py +++ b/tests/components/notion/conftest.py @@ -1,4 +1,5 @@ """Define fixtures for Notion tests.""" +from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch @@ -13,6 +14,15 @@ TEST_USERNAME = "user@host.com" TEST_PASSWORD = "password123" +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.notion.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="client") def client_fixture(data_bridge, data_sensor, data_task): """Define a fixture for an aionotion client.""" diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index d0d1cad0350c..e9f340fae17d 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -12,6 +12,8 @@ from homeassistant.core import HomeAssistant from .conftest import TEST_PASSWORD, TEST_USERNAME +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.mark.parametrize( ("get_client_with_exception", "errors"), @@ -58,7 +60,7 @@ async def test_create_entry( } -async def test_duplicate_error(hass: HomeAssistant, config, setup_config_entry) -> None: +async def test_duplicate_error(hass: HomeAssistant, config, config_entry) -> None: """Test that errors are shown when duplicates are added.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=config @@ -81,7 +83,7 @@ async def test_reauth( config_entry, errors, get_client_with_exception, - setup_config_entry, + mock_aionotion, ) -> None: """Test that re-auth works.""" result = await hass.config_entries.flow.async_init( From ed3cdd8fb9aec598819152fd85de520218699461 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 09:42:55 +0100 Subject: [PATCH 0152/1058] Fix lingering task in timeout test (#88953) --- tests/util/test_timeout.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/util/test_timeout.py b/tests/util/test_timeout.py index 60c87fa7434a..e89c6cd3f022 100644 --- a/tests/util/test_timeout.py +++ b/tests/util/test_timeout.py @@ -273,6 +273,9 @@ async def test_mix_zone_timeout_trigger_global_cool_down() -> None: await asyncio.sleep(0.2) + # Cleanup lingering (cool_down) task after test is done + await asyncio.sleep(0.3) + async def test_simple_zone_timeout_freeze_without_timeout_cleanup( hass: HomeAssistant, From 6febe00516cc32989048bcef0a707072b56c5acf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 10:49:13 +0100 Subject: [PATCH 0153/1058] Fix lingering task in entity_platform test (#88957) * Fix lingering task in entity_platform test * Speed up the test --- tests/helpers/test_entity_platform.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 441f47ff1269..597045f557f8 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -233,7 +233,7 @@ async def test_platform_error_slow_setup( async def setup_platform(*args): called.append(1) - await asyncio.sleep(1) + await asyncio.sleep(0.1) platform = MockPlatform(async_setup_platform=setup_platform) component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -244,6 +244,9 @@ async def test_platform_error_slow_setup( assert "test_domain.test_platform" not in hass.config.components assert "test_platform is taking longer than 0 seconds" in caplog.text + # Cleanup lingering (setup_platform) task after test is done + await asyncio.sleep(0.1) + async def test_updated_state_used_for_entity_id(hass: HomeAssistant) -> None: """Test that first update results used for entity ID generation.""" From 29b049fc57902b20a6e6db9c0c85b798fb5ba23c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 11:11:29 +0100 Subject: [PATCH 0154/1058] Don't create new venv if script/setup is run from within a venv (#88906) --- script/setup | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/setup b/script/setup index 9df3fdb25d8a..782eb5106546 100755 --- a/script/setup +++ b/script/setup @@ -16,7 +16,7 @@ fi mkdir -p config -if [ ! -n "$DEVCONTAINER" ];then +if [ ! -n "$DEVCONTAINER" ] && [ ! -n "$VIRTUAL_ENV" ];then python3 -m venv venv source venv/bin/activate fi @@ -36,4 +36,4 @@ logger: logs: homeassistant.components.cloud: debug " >> config/configuration.yaml -fi \ No newline at end of file +fi From fca5cc6ea3c3e96fa4224eb73821fe6695c33177 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 11:22:57 +0100 Subject: [PATCH 0155/1058] Add number + sensor device class volume storage (#88312) * Add number + sensor device class volume storage * Fix typo * Format code * Update device automations --- homeassistant/components/number/const.py | 13 +++++++++++++ homeassistant/components/sensor/const.py | 14 ++++++++++++++ .../components/sensor/device_condition.py | 1 + homeassistant/components/sensor/device_trigger.py | 1 + tests/components/sensor/test_device_condition.py | 2 ++ tests/components/sensor/test_device_trigger.py | 2 ++ 6 files changed, 33 insertions(+) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index aa57bb2b7162..48cd04dc26e8 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -324,6 +324,18 @@ class NumberDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_STORAGE = "volume_storage" + """Generic stored volume. + + Use this device class for sensors measuring stored volume, for example the amount + of fuel in a fuel tank. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + WATER = "water" """Water. @@ -411,6 +423,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.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8ded1c304da5..356eb68b4dbd 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -362,6 +362,18 @@ class SensorDeviceClass(StrEnum): USCS/imperial units are currently assumed to be US volumes) """ + VOLUME_STORAGE = "volume_storage" + """Generic stored volume. + + Use this device class for sensors measuring stored volume, for example the amount + of fuel in a fuel tank. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + WATER = "water" """Water. @@ -451,6 +463,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.TEMPERATURE: TemperatureConverter, SensorDeviceClass.VOLTAGE: ElectricPotentialConverter, SensorDeviceClass.VOLUME: VolumeConverter, + SensorDeviceClass.VOLUME_STORAGE: VolumeConverter, SensorDeviceClass.WATER: VolumeConverter, SensorDeviceClass.WEIGHT: MassConverter, SensorDeviceClass.WIND_SPEED: SpeedConverter, @@ -573,6 +586,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, + SensorDeviceClass.VOLUME_STORAGE: {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 6ed47cbf63e1..8547827d7488 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -122,6 +122,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.WATER: [{CONF_TYPE: CONF_IS_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_IS_WIND_SPEED}], diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 7e498321b3ec..3b2a0485554a 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -121,6 +121,7 @@ ENTITY_TRIGGERS = { ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}], + SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_VOLUME}], SensorDeviceClass.WATER: [{CONF_TYPE: CONF_WATER}], SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}], SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_WIND_SPEED}], diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 24d480e24b38..5e93bf2a64c4 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -52,6 +52,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.CO: "CONF_IS_CO", SensorDeviceClass.CO2: "CONF_IS_CO2", SensorDeviceClass.ENERGY_STORAGE: "CONF_IS_ENERGY", + SensorDeviceClass.VOLUME_STORAGE: "CONF_IS_VOLUME", }.get(device_class, f"CONF_IS_{device_class.value.upper()}") assert hasattr(device_condition, constant_name), f"Missing constant {constant_name}" @@ -59,6 +60,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: constant_value = { SensorDeviceClass.BATTERY: "is_battery_level", SensorDeviceClass.ENERGY_STORAGE: "is_energy", + SensorDeviceClass.VOLUME_STORAGE: "is_volume", }.get(device_class, f"is_{device_class.value}") assert getattr(device_condition, constant_name) == constant_value diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 34b5d6fb40fd..37f44a5b40dc 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -56,6 +56,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: SensorDeviceClass.CO: "CONF_CO", SensorDeviceClass.CO2: "CONF_CO2", SensorDeviceClass.ENERGY_STORAGE: "CONF_ENERGY", + SensorDeviceClass.VOLUME_STORAGE: "CONF_VOLUME", }.get(device_class, f"CONF_{device_class.value.upper()}") assert hasattr(device_trigger, constant_name), f"Missing constant {constant_name}" @@ -63,6 +64,7 @@ def test_matches_device_classes(device_class: SensorDeviceClass) -> None: constant_value = { SensorDeviceClass.BATTERY: "battery_level", SensorDeviceClass.ENERGY_STORAGE: "energy", + SensorDeviceClass.VOLUME_STORAGE: "volume", }.get(device_class, device_class.value) assert getattr(device_trigger, constant_name) == constant_value From 42a69566ac8ef8bed9042b62ba631d97c5ba918c Mon Sep 17 00:00:00 2001 From: Mitch Date: Wed, 1 Mar 2023 11:39:14 +0100 Subject: [PATCH 0156/1058] Bump nuheat to 1.0.1 (#88958) --- homeassistant/components/nuheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nuheat/manifest.json b/homeassistant/components/nuheat/manifest.json index 91b0a9eb194c..cda1e9b02dd7 100644 --- a/homeassistant/components/nuheat/manifest.json +++ b/homeassistant/components/nuheat/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuheat", "iot_class": "cloud_polling", "loggers": ["nuheat"], - "requirements": ["nuheat==1.0.0"] + "requirements": ["nuheat==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 58eb01371bf5..07e0316ab8f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1225,7 +1225,7 @@ nsapi==3.0.5 nsw-fuel-api-client==1.1.0 # homeassistant.components.nuheat -nuheat==1.0.0 +nuheat==1.0.1 # homeassistant.components.numato numato-gpio==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e23d75ecb2d..e1cf8432506f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -903,7 +903,7 @@ notify-events==1.0.4 nsw-fuel-api-client==1.1.0 # homeassistant.components.nuheat -nuheat==1.0.0 +nuheat==1.0.1 # homeassistant.components.numato numato-gpio==0.10.0 From ab9bd5c29e4f53242db184c73c2a86b100cf49fb Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Wed, 1 Mar 2023 03:01:54 -0800 Subject: [PATCH 0157/1058] Fix todoist filtering custom projects by labels (#87904) * Fix filtering custom projects by labels. * Don't lowercase the label. * Labels are case-sensitive, don't lowercase them. --- homeassistant/components/todoist/calendar.py | 5 ++- tests/components/todoist/test_calendar.py | 36 +++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 0a822d0515da..8fdafee6cfd8 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -94,7 +94,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ), vol.Optional( CONF_PROJECT_LABEL_WHITELIST, default=[] - ): vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]), + ): vol.All(cv.ensure_list, [vol.All(cv.string)]), } ) ] @@ -458,9 +458,8 @@ class TodoistProjectData: # All task Labels (optional parameter). task[LABELS] = [ - label.name.lower() for label in self._labels if label.id in data.labels + label.name for label in self._labels if label.name in data.labels ] - if self._label_whitelist and ( not any(label in task[LABELS] for label in self._label_whitelist) ): diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 4b55ac6859fb..fece314c91c1 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,4 +1,5 @@ """Unit tests for the Todoist calendar platform.""" +from datetime import datetime from unittest.mock import AsyncMock, patch import pytest @@ -9,6 +10,7 @@ from homeassistant.components.todoist.calendar import DOMAIN from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity_component import async_update_entity @pytest.fixture(name="task") @@ -23,9 +25,11 @@ def mock_task() -> Task: created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=Due(is_recurring=False, date="2022-01-01", string="today"), + due=Due( + is_recurring=False, date=datetime.now().strftime("%Y-%m-%d"), string="today" + ), id="1", - labels=[], + labels=["Label1"], order=1, parent_id=None, priority=1, @@ -37,7 +41,7 @@ def mock_task() -> Task: @pytest.fixture(name="api") -def mock_api() -> AsyncMock: +def mock_api(task) -> AsyncMock: """Mock the api state.""" api = AsyncMock() api.get_projects.return_value = [ @@ -57,9 +61,10 @@ def mock_api() -> AsyncMock: ) ] api.get_labels.return_value = [ - Label(id="1", name="label1", color="1", order=1, is_favorite=False) + Label(id="1", name="Label1", color="1", order=1, is_favorite=False) ] api.get_collaborators.return_value = [] + api.get_tasks.return_value = [task] return api @@ -84,6 +89,29 @@ async def test_calendar_entity_unique_id(todoist_api, hass: HomeAssistant, api) assert entity.unique_id == "12345" +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_update_entity_for_custom_project_with_labels_on(todoist_api, hass, api): + """Test that the calendar's state is on for a custom project using labels.""" + todoist_api.return_value = api + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], + } + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "calendar.all_projects") + state = hass.states.get("calendar.all_projects") + assert state.attributes["labels"] == ["Label1"] + assert state.state == "on" + + @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_custom_project_unique_id( todoist_api, hass: HomeAssistant, api From b75879194dbe79b97d40241ae6db391d41470b80 Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Wed, 1 Mar 2023 11:34:41 +0000 Subject: [PATCH 0158/1058] Fix geniushub heating hvac action (#87531) --- homeassistant/components/geniushub/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index 3f8fb0c68050..21ef28093609 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -79,10 +79,10 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): def hvac_action(self) -> str | None: """Return the current running hvac operation if supported.""" if "_state" in self._zone.data: # only for v3 API + if self._zone.data["output"] == 1: + return HVACAction.HEATING if not self._zone.data["_state"].get("bIsActive"): return HVACAction.OFF - if self._zone.data["_state"].get("bOutRequestHeat"): - return HVACAction.HEATING return HVACAction.IDLE return None From 85f2693353b006855d35c011c29d8c807343a8d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guardia?= <79667811+FredericGuardia@users.noreply.github.com> Date: Wed, 1 Mar 2023 12:54:07 +0100 Subject: [PATCH 0159/1058] Fix Google Assistant temperature attribute (#85921) --- homeassistant/components/google_assistant/trait.py | 2 +- tests/components/google_assistant/test_trait.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index af203906b86e..b248ffbac221 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -832,7 +832,7 @@ class TemperatureControlTrait(_Trait): "temperatureUnitForUX": _google_temp_unit( self.hass.config.units.temperature_unit ), - "queryOnlyTemperatureSetting": True, + "queryOnlyTemperatureControl": True, "temperatureRange": { "minThresholdCelsius": -100, "maxThresholdCelsius": 100, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index a04c74259d49..33eac82a6ba8 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1101,7 +1101,7 @@ async def test_temperature_control(hass: HomeAssistant) -> None: BASIC_CONFIG, ) assert trt.sync_attributes() == { - "queryOnlyTemperatureSetting": True, + "queryOnlyTemperatureControl": True, "temperatureUnitForUX": "C", "temperatureRange": {"maxThresholdCelsius": 100, "minThresholdCelsius": -100}, } @@ -2941,7 +2941,7 @@ async def test_temperature_control_sensor_data( ) assert trt.sync_attributes() == { - "queryOnlyTemperatureSetting": True, + "queryOnlyTemperatureControl": True, "temperatureUnitForUX": unit_out, "temperatureRange": {"maxThresholdCelsius": 100, "minThresholdCelsius": -100}, } From 0c66346fb089acee3cd6bb3fed761679fc371e9d Mon Sep 17 00:00:00 2001 From: Thibaut Date: Wed, 1 Mar 2023 13:46:26 +0100 Subject: [PATCH 0160/1058] Add dynamic unit of measurement support for Overkiz sensor (#80490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add dynamic unit support * Import all units * Fix typing * Add fallback to CORE_ELECTRIC_POWER_CONSUMPTION_STATE_MEASURED_VALUE_TYPE * Fix rebase * Give priority to the more accurate attribute * Don’t use hardcoded seconds unit * Don’t change SensorDescription * Rework comment --- homeassistant/components/overkiz/const.py | 62 +++++++++++++++++++++- homeassistant/components/overkiz/sensor.py | 30 ++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 806ba435c206..0db01a2d84c2 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -5,9 +5,28 @@ from datetime import timedelta import logging from typing import Final -from pyoverkiz.enums import OverkizCommandParam, UIClass, UIWidget +from pyoverkiz.enums import MeasuredValueType, OverkizCommandParam, UIClass, UIWidget -from homeassistant.const import Platform +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + DEGREE, + LIGHT_LUX, + PERCENTAGE, + Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfIrradiance, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, + UnitOfVolumeFlowRate, +) DOMAIN: Final = "overkiz" LOGGER: logging.Logger = logging.getLogger(__package__) @@ -98,3 +117,42 @@ OVERKIZ_STATE_TO_TRANSLATION: dict[str, str] = { OverkizCommandParam.SFC: "sfc", OverkizCommandParam.UPS: "ups", } + +OVERKIZ_UNIT_TO_HA: dict[str, str] = { + MeasuredValueType.ABSOLUTE_VALUE: "", + MeasuredValueType.ANGLE_IN_DEGREES: DEGREE, + MeasuredValueType.ANGULAR_SPEED_IN_DEGREES_PER_SECOND: f"{DEGREE}/{UnitOfTime.SECONDS}", + MeasuredValueType.ELECTRICAL_ENERGY_IN_KWH: UnitOfEnergy.KILO_WATT_HOUR, + MeasuredValueType.ELECTRICAL_ENERGY_IN_WH: UnitOfEnergy.WATT_HOUR, + MeasuredValueType.ELECTRICAL_POWER_IN_KW: UnitOfPower.KILO_WATT, + MeasuredValueType.ELECTRICAL_POWER_IN_W: UnitOfPower.WATT, + MeasuredValueType.ELECTRIC_CURRENT_IN_AMPERE: UnitOfElectricCurrent.AMPERE, + MeasuredValueType.ELECTRIC_CURRENT_IN_MILLI_AMPERE: UnitOfElectricCurrent.MILLIAMPERE, + MeasuredValueType.ENERGY_IN_CAL: "cal", + MeasuredValueType.ENERGY_IN_KCAL: "kcal", + MeasuredValueType.FLOW_IN_LITRE_PER_SECOND: f"{UnitOfVolume.LITERS}/{UnitOfTime.SECONDS}", + MeasuredValueType.FLOW_IN_METER_CUBE_PER_HOUR: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + MeasuredValueType.FLOW_IN_METER_CUBE_PER_SECOND: f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.SECONDS}", + MeasuredValueType.FOSSIL_ENERGY_IN_WH: UnitOfEnergy.WATT_HOUR, + MeasuredValueType.GRADIENT_IN_PERCENTAGE_PER_SECOND: f"{PERCENTAGE}/{UnitOfTime.SECONDS}", + MeasuredValueType.LENGTH_IN_METER: UnitOfLength.METERS, + MeasuredValueType.LINEAR_SPEED_IN_METER_PER_SECOND: UnitOfSpeed.METERS_PER_SECOND, + MeasuredValueType.LUMINANCE_IN_LUX: LIGHT_LUX, + MeasuredValueType.PARTS_PER_BILLION: CONCENTRATION_PARTS_PER_BILLION, + MeasuredValueType.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, + MeasuredValueType.PARTS_PER_QUADRILLION: "ppq", + MeasuredValueType.PARTS_PER_TRILLION: "ppt", + MeasuredValueType.POWER_PER_SQUARE_METER: UnitOfIrradiance.WATTS_PER_SQUARE_METER, + MeasuredValueType.PRESSURE_IN_HPA: UnitOfPressure.HPA, + MeasuredValueType.PRESSURE_IN_MILLI_BAR: UnitOfPressure.MBAR, + MeasuredValueType.RELATIVE_VALUE_IN_PERCENTAGE: PERCENTAGE, + MeasuredValueType.TEMPERATURE_IN_CELCIUS: UnitOfTemperature.CELSIUS, + MeasuredValueType.TEMPERATURE_IN_KELVIN: UnitOfTemperature.KELVIN, + MeasuredValueType.TIME_IN_SECOND: UnitOfTime.SECONDS, + # MeasuredValueType.VECTOR_COORDINATE: "", + MeasuredValueType.VOLTAGE_IN_MILLI_VOLT: UnitOfElectricPotential.MILLIVOLT, + MeasuredValueType.VOLTAGE_IN_VOLT: UnitOfElectricPotential.VOLT, + MeasuredValueType.VOLUME_IN_CUBIC_METER: UnitOfVolume.CUBIC_METERS, + MeasuredValueType.VOLUME_IN_GALLON: UnitOfVolume.GALLONS, + MeasuredValueType.VOLUME_IN_LITER: UnitOfVolume.LITERS, +} diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 1e37d938cc6e..4c70bab70f5b 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -34,7 +34,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import HomeAssistantOverkizData -from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES, OVERKIZ_STATE_TO_TRANSLATION +from .const import ( + DOMAIN, + IGNORED_OVERKIZ_DEVICES, + OVERKIZ_STATE_TO_TRANSLATION, + OVERKIZ_UNIT_TO_HA, +) from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity, OverkizEntity @@ -473,6 +478,29 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): return state.value + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if ( + not (default_unit := self.entity_description.native_unit_of_measurement) + or not (state := self.device.states.get(self.entity_description.key)) + or not state.value + ): + return default_unit + + attrs = self.device.attributes + if (unit := attrs[f"{state.name}MeasuredValueType"]) and ( + unit_value := unit.value_as_str + ): + return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit) + + if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and ( + unit_value := unit.value_as_str + ): + return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit) + + return default_unit + class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity): """Representation of an Overkiz HomeKit Setup Code.""" From 23cdafd12f5a04a28d91a413471277eeca9d6ceb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 15:26:39 +0100 Subject: [PATCH 0161/1058] Use UnitOfVolumeFlowRate in huisbaasje and plugwise (#88967) --- homeassistant/components/huisbaasje/const.py | 4 ---- homeassistant/components/huisbaasje/sensor.py | 11 ++++++++--- homeassistant/components/plugwise/sensor.py | 4 ++-- tests/components/huisbaasje/test_sensor.py | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/huisbaasje/const.py b/homeassistant/components/huisbaasje/const.py index 9931b33a9968..f90848312636 100644 --- a/homeassistant/components/huisbaasje/const.py +++ b/homeassistant/components/huisbaasje/const.py @@ -8,14 +8,10 @@ from energyflip.const import ( SOURCE_TYPE_GAS, ) -from homeassistant.const import UnitOfTime, UnitOfVolume - DATA_COORDINATOR = "coordinator" DOMAIN = "huisbaasje" -FLOW_CUBIC_METERS_PER_HOUR = f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.HOURS}" - """Interval in seconds between polls to huisbaasje.""" POLLING_INTERVAL = 20 diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index f73d4bf31298..369c6eba0750 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -21,7 +21,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID, UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.const import ( + CONF_ID, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -32,7 +38,6 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( DATA_COORDINATOR, DOMAIN, - FLOW_CUBIC_METERS_PER_HOUR, SENSOR_TYPE_RATE, SENSOR_TYPE_THIS_DAY, SENSOR_TYPE_THIS_MONTH, @@ -179,7 +184,7 @@ SENSORS_INFO = [ ), HuisbaasjeSensorEntityDescription( name="Huisbaasje Current Gas", - native_unit_of_measurement=FLOW_CUBIC_METERS_PER_HOUR, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, sensor_type=SENSOR_TYPE_RATE, state_class=SensorStateClass.MEASUREMENT, key=SOURCE_TYPE_GAS, diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 354656ecd9ea..d708fe741c2e 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -17,8 +17,8 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, - UnitOfTime, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -305,7 +305,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( key="gas_consumed_interval", name="Gas consumed interval", icon="mdi:meter-gas", - native_unit_of_measurement=f"{UnitOfVolume.CUBIC_METERS}/{UnitOfTime.HOURS}", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 53734cde4896..d3a65a0be6dc 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import patch from homeassistant.components import huisbaasje -from homeassistant.components.huisbaasje.const import FLOW_CUBIC_METERS_PER_HOUR from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -18,6 +17,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfPower, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant @@ -292,7 +292,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert ( current_gas.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == FLOW_CUBIC_METERS_PER_HOUR + == UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR ) gas_today = hass.states.get("sensor.huisbaasje_gas_today") From 9762b684c248a65ce82340236adf026333a2de4d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:04:40 +0100 Subject: [PATCH 0162/1058] Adjust entity registry access in tests (3) (#88964) --- tests/components/sonos/test_number.py | 6 +- tests/components/sonos/test_sensor.py | 45 ++++++----- tests/components/sonos/test_switch.py | 19 ++--- .../components/template/test_binary_sensor.py | 33 ++++---- tests/components/template/test_sensor.py | 39 ++++----- .../components/universal/test_media_player.py | 12 ++- tests/components/utility_meter/test_sensor.py | 13 +-- tests/components/webostv/test_media_player.py | 14 ++-- tests/components/wemo/test_wemo_device.py | 21 ++--- tests/components/whirlpool/test_sensor.py | 19 ++--- .../xiaomi_ble/test_device_trigger.py | 55 +++++++------ tests/components/zha/common.py | 8 +- tests/components/zha/test_discover.py | 6 +- .../components/zwave_js/test_device_action.py | 52 ++++++------ .../zwave_js/test_device_condition.py | 79 +++++++++++++------ tests/components/zwave_js/test_fan.py | 41 ++++++---- 16 files changed, 252 insertions(+), 210 deletions(-) diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index f63c1a1ee0ab..a393f699a575 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -4,15 +4,13 @@ from unittest.mock import patch from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er async def test_number_entities( - hass: HomeAssistant, async_autosetup_sonos, soco + hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test number entities.""" - entity_registry = ent_reg.async_get(hass) - bass_number = entity_registry.entities["number.zone_a_bass"] bass_state = hass.states.get(bass_number.entity_id) assert bass_state.state == "1" diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 6c66435e640b..2d7a9322aeb6 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sonos.binary_sensor import ATTR_BATTERY_POWER_SOUR from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from .conftest import SonosMockEvent @@ -19,37 +19,31 @@ from tests.common import async_fire_time_changed async def test_entity_registry_unsupported( - hass: HomeAssistant, async_setup_sonos, soco + hass: HomeAssistant, async_setup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test sonos device without battery registered in the device registry.""" soco.get_battery_info.side_effect = NotSupportedException await async_setup_sonos() - entity_registry = ent_reg.async_get(hass) - assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" not in entity_registry.entities assert "binary_sensor.zone_a_power" not in entity_registry.entities async def test_entity_registry_supported( - hass: HomeAssistant, async_autosetup_sonos, soco + hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test sonos device with battery registered in the device registry.""" - entity_registry = ent_reg.async_get(hass) - assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" in entity_registry.entities assert "binary_sensor.zone_a_power" in entity_registry.entities async def test_battery_attributes( - hass: HomeAssistant, async_autosetup_sonos, soco + hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: """Test sonos device with battery state.""" - entity_registry = ent_reg.async_get(hass) - battery = entity_registry.entities["sensor.zone_a_battery"] battery_state = hass.states.get(battery.entity_id) assert battery_state.state == "100" @@ -64,7 +58,11 @@ async def test_battery_attributes( async def test_battery_on_s1( - hass: HomeAssistant, async_setup_sonos, soco, device_properties_event + hass: HomeAssistant, + async_setup_sonos, + soco, + device_properties_event, + entity_registry: er.EntityRegistry, ) -> None: """Test battery state updates on a Sonos S1 device.""" soco.get_battery_info.return_value = {} @@ -74,8 +72,6 @@ async def test_battery_on_s1( subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback - entity_registry = ent_reg.async_get(hass) - assert "sensor.zone_a_battery" not in entity_registry.entities assert "binary_sensor.zone_a_power" not in entity_registry.entities @@ -142,11 +138,14 @@ async def test_device_payload_without_battery_and_ignored_keys( async def test_audio_input_sensor( - hass: HomeAssistant, async_autosetup_sonos, soco, tv_event, no_media_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + tv_event, + no_media_event, + entity_registry: er.EntityRegistry, ) -> None: """Test audio input sensor.""" - entity_registry = ent_reg.async_get(hass) - subscription = soco.avTransport.subscribe.return_value sub_callback = subscription.callback sub_callback(tv_event) @@ -183,10 +182,13 @@ async def test_audio_input_sensor( async def test_microphone_binary_sensor( - hass: HomeAssistant, async_autosetup_sonos, soco, device_properties_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + device_properties_event, + entity_registry: er.EntityRegistry, ) -> None: """Test microphone binary sensor.""" - entity_registry = ent_reg.async_get(hass) assert "binary_sensor.zone_a_microphone" in entity_registry.entities mic_binary_sensor = entity_registry.entities["binary_sensor.zone_a_microphone"] @@ -203,10 +205,13 @@ async def test_microphone_binary_sensor( async def test_favorites_sensor( - hass: HomeAssistant, async_autosetup_sonos, soco, fire_zgs_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + fire_zgs_event, + entity_registry: er.EntityRegistry, ) -> None: """Test Sonos favorites sensor.""" - entity_registry = ent_reg.async_get(hass) favorites = entity_registry.entities["sensor.sonos_favorites"] assert hass.states.get(favorites.entity_id) is None diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index fbb3f420369d..405d99f5a17a 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -15,7 +15,7 @@ from homeassistant.components.sonos.switch import ( from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_TIME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt from .conftest import SonosMockEvent @@ -23,10 +23,10 @@ from .conftest import SonosMockEvent from tests.common import async_fire_time_changed -async def test_entity_registry(hass: HomeAssistant, async_autosetup_sonos) -> None: +async def test_entity_registry( + hass: HomeAssistant, async_autosetup_sonos, entity_registry: er.EntityRegistry +) -> None: """Test sonos device with alarm registered in the device registry.""" - entity_registry = ent_reg.async_get(hass) - assert "media_player.zone_a" in entity_registry.entities assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.zone_a_status_light" in entity_registry.entities @@ -39,11 +39,13 @@ async def test_entity_registry(hass: HomeAssistant, async_autosetup_sonos) -> No async def test_switch_attributes( - hass: HomeAssistant, async_autosetup_sonos, soco, fire_zgs_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + fire_zgs_event, + entity_registry: er.EntityRegistry, ) -> None: """Test for correct Sonos switch states.""" - entity_registry = ent_reg.async_get(hass) - alarm = entity_registry.entities["switch.sonos_alarm_14"] alarm_state = hass.states.get(alarm.entity_id) assert alarm_state.state == STATE_ON @@ -135,10 +137,9 @@ async def test_alarm_create_delete( alarm_clock, alarm_clock_extended, alarm_event, + entity_registry: er.EntityRegistry, ) -> None: """Test for correct creation and deletion of alarms during runtime.""" - entity_registry = ent_reg.async_get(hass) - one_alarm = copy(alarm_clock.ListAlarms.return_value) two_alarms = copy(alarm_clock_extended.ListAlarms.return_value) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index b34a26ceeb3a..6483524545d9 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -804,22 +804,18 @@ async def test_no_update_template_match_all( }, ], ) -async def test_unique_id(hass: HomeAssistant, start_ha) -> None: +async def test_unique_id( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option only creates one binary sensor per id.""" assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - - assert len(ent_reg.entities) == 2 - assert ( - ent_reg.async_get_entity_id("binary_sensor", "template", "group-id-sensor-id") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id( + "binary_sensor", "template", "group-id-sensor-id" ) - assert ( - ent_reg.async_get_entity_id( - "binary_sensor", "template", "not-so-unique-anymore" - ) - is not None + assert entity_registry.async_get_entity_id( + "binary_sensor", "template", "not-so-unique-anymore" ) @@ -1052,7 +1048,9 @@ async def test_restore_state( }, ], ) -async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: +async def test_trigger_entity( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test trigger entity works.""" await hass.async_block_till_done() state = hass.states.get("binary_sensor.hello_name") @@ -1075,14 +1073,13 @@ async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: assert state.attributes.get("plus_one") == 3 assert state.context is context - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 assert ( - ent_reg.entities["binary_sensor.hello_name"].unique_id + entity_registry.entities["binary_sensor.hello_name"].unique_id == "listening-test-event-hello_name-id" ) assert ( - ent_reg.entities["binary_sensor.via_list"].unique_id + entity_registry.entities["binary_sensor.via_list"].unique_id == "listening-test-event-via_list-id" ) diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 3f407795c5c5..d3e3ebf58122 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.template import Template from homeassistant.setup import ATTR_COMPONENT, async_setup_component @@ -558,19 +558,18 @@ async def test_no_template_match_all( }, ], ) -async def test_unique_id(hass: HomeAssistant, start_ha) -> None: +async def test_unique_id( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test unique_id option only creates one sensor per id.""" assert len(hass.states.async_all()) == 2 - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 2 - assert ( - ent_reg.async_get_entity_id("sensor", "template", "group-id-sensor-id") - is not None + assert len(entity_registry.entities) == 2 + assert entity_registry.async_get_entity_id( + "sensor", "template", "group-id-sensor-id" ) - assert ( - ent_reg.async_get_entity_id("sensor", "template", "not-so-unique-anymore") - is not None + assert entity_registry.async_get_entity_id( + "sensor", "template", "not-so-unique-anymore" ) @@ -1094,7 +1093,9 @@ async def test_duplicate_templates(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: +async def test_trigger_entity( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test trigger entity works.""" state = hass.states.get("sensor.hello_name") assert state is not None @@ -1117,14 +1118,13 @@ async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: assert state.attributes.get("unit_of_measurement") == "%" assert state.context is context - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 2 + assert len(entity_registry.entities) == 2 assert ( - ent_reg.entities["sensor.hello_name"].unique_id + entity_registry.entities["sensor.hello_name"].unique_id == "listening-test-event-hello_name-id" ) assert ( - ent_reg.entities["sensor.via_list"].unique_id + entity_registry.entities["sensor.via_list"].unique_id == "listening-test-event-via_list-id" ) @@ -1157,7 +1157,9 @@ async def test_trigger_entity(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_trigger_entity_render_error(hass: HomeAssistant, start_ha) -> None: +async def test_trigger_entity_render_error( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: """Test trigger entity handles render error.""" state = hass.states.get("sensor.hello") assert state is not None @@ -1170,9 +1172,8 @@ async def test_trigger_entity_render_error(hass: HomeAssistant, start_ha) -> Non state = hass.states.get("sensor.hello") assert state.state == STATE_UNAVAILABLE - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 1 - assert ent_reg.entities["sensor.hello"].unique_id == "no-base-id" + assert len(entity_registry.entities) == 1 + assert entity_registry.entities["sensor.hello"].unique_id == "no-base-id" @pytest.mark.parametrize(("count", "domain"), [(0, sensor.DOMAIN)]) diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index fd8c572685cb..12d7b444097d 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -22,7 +22,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component @@ -1183,7 +1183,9 @@ async def test_device_class(hass: HomeAssistant) -> None: assert hass.states.get("media_player.tv").attributes["device_class"] == "tv" -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id property.""" hass.states.async_set("sensor.test_sensor", "on") @@ -1199,8 +1201,10 @@ async def test_unique_id(hass: HomeAssistant) -> None: }, ) await hass.async_block_till_done() - er = entity_registry.async_get(hass) - assert er.async_get("media_player.tv").unique_id == "universal_master_bed_tv" + assert ( + entity_registry.async_get("media_player.tv").unique_id + == "universal_master_bed_tv" + ) async def test_invalid_state_template(hass: HomeAssistant) -> None: diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 58cf616748ec..c56010e36e55 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -38,7 +38,7 @@ from homeassistant.const import ( UnitOfEnergy, ) from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -324,7 +324,9 @@ async def test_init(hass: HomeAssistant, yaml_config, config_entry_config) -> No assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR -async def test_unique_id(hass: HomeAssistant) -> None: +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test unique_id configuration option.""" yaml_config = { "utility_meter": { @@ -342,10 +344,9 @@ async def test_unique_id(hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - ent_reg = entity_registry.async_get(hass) - assert len(ent_reg.entities) == 4 - assert ent_reg.entities["select.energy_bill"].unique_id == "1" - assert ent_reg.entities["sensor.energy_bill_onpeak"].unique_id == "1_onpeak" + assert len(entity_registry.entities) == 4 + assert entity_registry.entities["select.energy_bill"].unique_id == "1" + assert entity_registry.entities["sensor.energy_bill_onpeak"].unique_id == "1_onpeak" @pytest.mark.parametrize( diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 5ecd1a5d7be8..afc7bca513c2 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -61,7 +61,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt @@ -275,7 +275,7 @@ async def test_select_sound_output(hass: HomeAssistant, client) -> None: async def test_device_info_startup_off( - hass: HomeAssistant, client, monkeypatch + hass: HomeAssistant, client, monkeypatch, device_registry: dr.DeviceRegistry ) -> None: """Test device info when device is off at startup.""" monkeypatch.setattr(client, "system_info", None) @@ -285,8 +285,7 @@ async def test_device_info_startup_off( assert hass.states.get(ENTITY_ID).state == STATE_OFF - device_reg = device_registry.async_get(hass) - device = device_reg.async_get_device({(DOMAIN, entry.unique_id)}) + device = device_registry.async_get_device({(DOMAIN, entry.unique_id)}) assert device assert device.identifiers == {(DOMAIN, entry.unique_id)} @@ -296,7 +295,9 @@ async def test_device_info_startup_off( assert device.model is None -async def test_entity_attributes(hass: HomeAssistant, client, monkeypatch) -> None: +async def test_entity_attributes( + hass: HomeAssistant, client, monkeypatch, device_registry: dr.DeviceRegistry +) -> None: """Test entity attributes.""" entry = await setup_webostv(hass) await client.mock_state_update() @@ -331,8 +332,7 @@ async def test_entity_attributes(hass: HomeAssistant, client, monkeypatch) -> No assert attrs[ATTR_MEDIA_TITLE] == "Channel Name 2" # Device Info - device_reg = device_registry.async_get(hass) - device = device_reg.async_get_device({(DOMAIN, entry.unique_id)}) + device = device_registry.async_get_device({(DOMAIN, entry.unique_id)}) assert device assert device.identifiers == {(DOMAIN, entry.unique_id)} diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index b447a9b24ff4..49c6664f7bb4 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -12,7 +12,7 @@ from homeassistant import runner from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC, wemo_device from homeassistant.components.wemo.const import DOMAIN, WEMO_SUBSCRIPTION_EVENT from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -31,7 +31,7 @@ def pywemo_model(): async def test_async_register_device_longpress_fails( - hass: HomeAssistant, pywemo_device + hass: HomeAssistant, pywemo_device, device_registry: dr.DeviceRegistry ) -> None: """Device is still registered if ensure_long_press_virtual_device fails.""" with patch.object(pywemo_device, "ensure_long_press_virtual_device") as elp: @@ -47,8 +47,7 @@ async def test_async_register_device_longpress_fails( }, ) await hass.async_block_till_done() - dr = device_registry.async_get(hass) - device_entries = list(dr.devices.values()) + device_entries = list(device_registry.devices.values()) assert len(device_entries) == 1 device = wemo_device.async_get_coordinator(hass, device_entries[0].id) assert device.supports_long_press is False @@ -164,10 +163,11 @@ async def test_async_update_data_subscribed( pywemo_device.get_state.assert_not_called() -async def test_device_info(hass: HomeAssistant, wemo_entity) -> None: +async def test_device_info( + hass: HomeAssistant, wemo_entity, device_registry: dr.DeviceRegistry +) -> None: """Verify the DeviceInfo data is set properly.""" - dr = device_registry.async_get(hass) - device_entries = list(dr.devices.values()) + device_entries = list(device_registry.devices.values()) assert len(device_entries) == 1 assert device_entries[0].connections == { @@ -178,10 +178,11 @@ async def test_device_info(hass: HomeAssistant, wemo_entity) -> None: assert device_entries[0].sw_version == MOCK_FIRMWARE_VERSION -async def test_dli_device_info(hass: HomeAssistant, wemo_dli_entity) -> None: +async def test_dli_device_info( + hass: HomeAssistant, wemo_dli_entity, device_registry: dr.DeviceRegistry +) -> None: """Verify the DeviceInfo data for Digital Loggers emulated wemo device.""" - dr = device_registry.async_get(hass) - device_entries = list(dr.devices.values()) + device_entries = list(device_registry.devices.values()) assert device_entries[0].configuration_url == "http://127.0.0.1" assert device_entries[0].identifiers == {(DOMAIN, "123456789")} diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index eef13c08dc90..429e8895ad85 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock from whirlpool.washerdryer import MachineState from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import as_timestamp, utc_from_timestamp from . import init_integration @@ -44,6 +44,7 @@ async def test_dryer_sensor_values( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, mock_sensor2_api: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running @@ -69,8 +70,7 @@ async def test_dryer_sensor_values( entity_id = "sensor.dryer_state" mock_instance = mock_sensor2_api - registry = entity_registry.async_get(hass) - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry state = hass.states.get(entity_id) assert state is not None @@ -108,6 +108,7 @@ async def test_washer_sensor_values( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, mock_sensor1_api: MagicMock, + entity_registry: er.EntityRegistry, ) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running @@ -133,8 +134,7 @@ async def test_washer_sensor_values( entity_id = "sensor.washer_state" mock_instance = mock_sensor1_api - registry = entity_registry.async_get(hass) - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry state = hass.states.get(entity_id) assert state is not None @@ -147,13 +147,14 @@ async def test_washer_sensor_values( assert state.state == thetimestamp.isoformat() state_id = f"{entity_id.split('_')[0]}_detergent_level" - registry = entity_registry.async_get(hass) - entry = registry.async_get(state_id) + entry = entity_registry.async_get(state_id) assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - update_entry = registry.async_update_entity(entry.entity_id, disabled_by=None) + update_entry = entity_registry.async_update_entity( + entry.entity_id, disabled_by=None + ) await hass.async_block_till_done() assert update_entry != entry diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index f39354ecac68..85454959cf44 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -19,8 +19,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry -from homeassistant.helpers.device_registry import async_get as async_get_dev_reg +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from . import make_advertisement @@ -82,7 +81,9 @@ async def test_event_motion_detected(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers(hass: HomeAssistant) -> None: +async def test_get_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> 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) @@ -98,8 +99,7 @@ async def test_get_triggers(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device({get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -118,7 +118,9 @@ async def test_get_triggers(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> None: +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" entry = await _async_setup_xiaomi_device(hass, mac) @@ -134,8 +136,7 @@ async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> await hass.async_block_till_done() assert len(events) == 1 - dev_reg = async_get_dev_reg(hass) - invalid_device = dev_reg.async_get_or_create( + invalid_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "invdevmac")}, ) @@ -149,7 +150,9 @@ async def test_get_triggers_for_invalid_xiami_ble_device(hass: HomeAssistant) -> await hass.async_block_till_done() -async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: +async def test_get_triggers_for_invalid_device_id( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> 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) @@ -163,11 +166,9 @@ async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - - invalid_device = dev_reg.async_get_or_create( + invalid_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert invalid_device triggers = await async_get_device_automations( @@ -179,7 +180,9 @@ async def test_get_triggers_for_invalid_device_id(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: +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) @@ -193,8 +196,7 @@ async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device({get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -237,7 +239,9 @@ async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: async def test_automation_with_invalid_trigger_type( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, ) -> None: """Test for automation with invalid trigger type.""" mac = "DE:70:E8:B2:39:0C" @@ -252,8 +256,7 @@ async def test_automation_with_invalid_trigger_type( # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device({get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -285,7 +288,9 @@ async def test_automation_with_invalid_trigger_type( async def test_automation_with_invalid_trigger_event_property( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + 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" @@ -300,8 +305,7 @@ async def test_automation_with_invalid_trigger_event_property( # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device({get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -332,7 +336,9 @@ async def test_automation_with_invalid_trigger_event_property( await hass.async_block_till_done() -async def test_triggers_for_invalid__model(hass: HomeAssistant, calls) -> None: +async def test_triggers_for_invalid__model( + hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry +) -> None: """Test invalid model doesn't return triggers.""" mac = "DE:70:E8:B2:39:0C" entry = await _async_setup_xiaomi_device(hass, mac) @@ -346,9 +352,8 @@ async def test_triggers_for_invalid__model(hass: HomeAssistant, calls) -> None: # wait for the event await hass.async_block_till_done() - dev_reg = async_get_dev_reg(hass) # modify model to invalid model - invalid_model = dev_reg.async_get_or_create( + invalid_model = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, mac)}, model="invalid model", diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index ff819413fc59..cae67f8d768e 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -10,7 +10,7 @@ import zigpy.zcl.foundation as zcl_f import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.helpers import async_get_zha_config_value -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed @@ -157,12 +157,10 @@ def find_entity_ids(domain, zha_device, hass): machine so that we can test state changes. """ - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) return [ entity.entity_id - for entity in entity_registry.async_entries_for_device( - registry, zha_device.device_id - ) + for entity in er.async_entries_for_device(registry, zha_device.device_id) if entity.domain == domain ] diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index e6b0cb77be3a..20db04d96150 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -28,7 +28,7 @@ import homeassistant.components.zha.sensor import homeassistant.components.zha.switch from homeassistant.const import Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry +import homeassistant.helpers.entity_registry as er from .common import get_zha_gateway from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -104,9 +104,7 @@ async def test_devices( zha_device_joined_restored, ) -> None: """Test device discovery.""" - entity_registry = homeassistant.helpers.entity_registry.async_get( - hass_disable_services - ) + entity_registry = er.async_get(hass_disable_services) zigpy_device = zigpy_device_mock( device[SIG_ENDPOINTS], diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index de94cb6c5cd5..8672e886ab5c 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.setup import async_setup_component from tests.common import async_get_device_automations @@ -26,13 +26,13 @@ async def test_get_actions( client: Client, lock_schlage_be469: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected actions from a zwave_js node.""" node = lock_schlage_be469 - dev_reg = device_registry.async_get(hass) driver = client.driver assert driver - device = dev_reg.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device({get_device_id(driver, node)}) assert device expected_actions = [ { @@ -92,7 +92,7 @@ async def test_get_actions( assert action in actions # Test that we don't return actions for a controller node - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(driver, client.driver.controller.nodes[1])} ) assert device @@ -107,13 +107,13 @@ async def test_get_actions_meter( client: Client, aeon_smart_switch_6: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected meter actions from a zwave_js node.""" node = aeon_smart_switch_6 - dev_reg = device_registry.async_get(hass) driver = client.driver assert driver - device = dev_reg.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device({get_device_id(driver, node)}) assert device actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device.id @@ -127,14 +127,14 @@ async def test_actions( client: Client, climate_radio_thermostat_ct100_plus: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test actions.""" node = climate_radio_thermostat_ct100_plus driver = client.driver assert driver device_id = get_device_id(driver, node) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device({device_id}) + device = device_registry.async_get_device({device_id}) assert device assert await async_setup_component( @@ -249,14 +249,14 @@ async def test_actions_multiple_calls( client: Client, climate_radio_thermostat_ct100_plus: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test actions can be called multiple times and still work.""" node = climate_radio_thermostat_ct100_plus driver = client.driver assert driver device_id = get_device_id(driver, node) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device({device_id}) + device = device_registry.async_get_device({device_id}) assert device assert await async_setup_component( @@ -296,14 +296,14 @@ async def test_lock_actions( client: Client, lock_schlage_be469: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test actions for locks.""" node = lock_schlage_be469 driver = client.driver assert driver device_id = get_device_id(driver, node) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device({device_id}) + device = device_registry.async_get_device({device_id}) assert device assert await async_setup_component( @@ -367,14 +367,14 @@ async def test_reset_meter_action( client: Client, aeon_smart_switch_6: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test reset_meter action.""" node = aeon_smart_switch_6 driver = client.driver assert driver device_id = get_device_id(driver, node) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device({device_id}) + device = device_registry.async_get_device({device_id}) assert device assert await async_setup_component( @@ -415,10 +415,10 @@ async def test_get_action_capabilities( client: Client, climate_radio_thermostat_ct100_plus: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected action capabilities.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, climate_radio_thermostat_ct100_plus)} ) assert device @@ -582,12 +582,10 @@ async def test_get_action_capabilities_lock_triggers( client: Client, lock_schlage_be469: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected action capabilities for lock triggers.""" - dev_reg = device_registry.async_get(hass) - device = device_registry.async_entries_for_config_entry( - dev_reg, integration.entry_id - )[0] + device = dr.async_entries_for_config_entry(device_registry, integration.entry_id)[0] # Test clear_lock_usercode capabilities = await device_action.async_get_action_capabilities( @@ -632,13 +630,13 @@ async def test_get_action_capabilities_meter_triggers( client: Client, aeon_smart_switch_6: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected action capabilities for meter triggers.""" node = aeon_smart_switch_6 - dev_reg = device_registry.async_get(hass) driver = client.driver assert driver - device = dev_reg.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device({get_device_id(driver, node)}) assert device capabilities = await device_action.async_get_action_capabilities( hass, @@ -662,12 +660,10 @@ async def test_failure_scenarios( client: Client, hank_binary_switch: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test failure scenarios.""" - dev_reg = device_registry.async_get(hass) - device = device_registry.async_entries_for_config_entry( - dev_reg, integration.entry_id - )[0] + device = dr.async_entries_for_config_entry(device_registry, integration.entry_id)[0] with pytest.raises(HomeAssistantError): await device_action.async_call_action_from_config( @@ -687,16 +683,16 @@ async def test_unavailable_entity_actions( client: Client, lock_schlage_be469: Node, integration: ConfigEntry, + device_registry: dr.DeviceRegistry, ) -> None: """Test unavailable entities are not included in actions list.""" entity_id_unavailable = "binary_sensor.touchscreen_deadbolt_home_security_intrusion" hass.states.async_set(entity_id_unavailable, STATE_UNAVAILABLE, force_update=True) await hass.async_block_till_done() node = lock_schlage_be469 - dev_reg = device_registry.async_get(hass) driver = client.driver assert driver - device = dev_reg.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device({get_device_id(driver, node)}) assert device actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device.id diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 3902a29861f5..b66e804eb80c 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -21,7 +21,7 @@ from homeassistant.components.zwave_js.helpers import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.setup import async_setup_component from tests.common import async_get_device_automations, async_mock_service @@ -34,11 +34,14 @@ def calls(hass): async def test_get_conditions( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected onditions from a zwave_js.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -78,7 +81,7 @@ async def test_get_conditions( assert condition in conditions # Test that we don't return actions for a controller node - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, client.driver.controller.nodes[1])} ) assert device @@ -91,11 +94,15 @@ async def test_get_conditions( async def test_node_status_state( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + calls, + device_registry: dr.DeviceRegistry, ) -> None: """Test for node_status conditions.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -252,11 +259,15 @@ async def test_node_status_state( async def test_config_parameter_state( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + calls, + device_registry: dr.DeviceRegistry, ) -> None: """Test for config_parameter conditions.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -368,11 +379,15 @@ async def test_config_parameter_state( async def test_value_state( - hass: HomeAssistant, client, lock_schlage_be469, integration, calls + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + calls, + device_registry: dr.DeviceRegistry, ) -> None: """Test for value conditions.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -416,11 +431,14 @@ async def test_value_state( async def test_get_condition_capabilities_node_status( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test we don't get capabilities from a node_status condition.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -453,11 +471,14 @@ async def test_get_condition_capabilities_node_status( async def test_get_condition_capabilities_value( - hass: HomeAssistant, client, lock_schlage_be469, integration + hass: HomeAssistant, + client, + lock_schlage_be469, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected capabilities from a value condition.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -502,12 +523,15 @@ async def test_get_condition_capabilities_value( async def test_get_condition_capabilities_config_parameter( - hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test we get the expected capabilities from a config_parameter condition.""" node = climate_radio_thermostat_ct100_plus - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, climate_radio_thermostat_ct100_plus)} ) assert device @@ -585,11 +609,14 @@ async def test_get_condition_capabilities_config_parameter( async def test_failure_scenarios( - hass: HomeAssistant, client, hank_binary_switch, integration + hass: HomeAssistant, + client, + hank_binary_switch, + integration, + device_registry: dr.DeviceRegistry, ) -> None: """Test failure scenarios.""" - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_device( + device = device_registry.async_get_device( {get_device_id(client.driver, hank_binary_switch)} ) assert device diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 6af7a1ba9f3c..d9de2379ce42 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er async def test_generic_fan( @@ -538,23 +538,26 @@ async def test_leviton_zw4sf_fan( async def test_thermostat_fan( - hass: HomeAssistant, client, climate_adc_t3000, integration + hass: HomeAssistant, + client, + climate_adc_t3000, + integration, + entity_registry: er.EntityRegistry, ) -> None: """Test the fan entity for a z-wave fan.""" node = climate_adc_t3000 entity_id = "fan.adc_t3000" - registry = entity_registry.async_get(hass) state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity(entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity(entity_id, disabled_by=None) assert updated_entry != entry assert updated_entry.disabled is False @@ -769,22 +772,25 @@ async def test_thermostat_fan( async def test_thermostat_fan_without_off( - hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus, + integration, + entity_registry: er.EntityRegistry, ) -> None: """Test the fan entity for a z-wave fan without "off" property.""" entity_id = "fan.z_wave_thermostat" - registry = entity_registry.async_get(hass) state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity(entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity(entity_id, disabled_by=None) assert updated_entry != entry assert updated_entry.disabled is False @@ -827,22 +833,25 @@ async def test_thermostat_fan_without_off( async def test_thermostat_fan_without_preset_modes( - hass: HomeAssistant, client, climate_adc_t3000_missing_fan_mode_states, integration + hass: HomeAssistant, + client, + climate_adc_t3000_missing_fan_mode_states, + integration, + entity_registry: er.EntityRegistry, ) -> None: """Test the fan entity for a z-wave fan without "states" metadata.""" entity_id = "fan.adc_t3000_missing_fan_mode_states" - registry = entity_registry.async_get(hass) state = hass.states.get(entity_id) assert state is None - entry = registry.async_get(entity_id) + entry = entity_registry.async_get(entity_id) assert entry assert entry.disabled - assert entry.disabled_by is entity_registry.RegistryEntryDisabler.INTEGRATION + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION # Test enabling entity - updated_entry = registry.async_update_entity(entity_id, disabled_by=None) + updated_entry = entity_registry.async_update_entity(entity_id, disabled_by=None) assert updated_entry != entry assert updated_entry.disabled is False From b94dffb7d3e157056ed3bbc9b56312cfe88399bc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:11:21 +0100 Subject: [PATCH 0163/1058] Add missing mock in esphome tests (#88923) --- tests/components/esphome/test_config_flow.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 2b8038564c4e..f96ecc8327f8 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -592,7 +592,10 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( async def test_reauth_fixed_via_remove_password( - hass: HomeAssistant, mock_client, mock_config_entry + hass: HomeAssistant, + mock_client, + mock_config_entry, + mock_dashboard, ) -> None: """Test reauth fixed automatically by seeing password removed.""" mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") @@ -799,7 +802,7 @@ async def test_discovery_dhcp_no_changes(hass: HomeAssistant, mock_client) -> No assert entry.data[CONF_HOST] == "192.168.43.183" -async def test_discovery_hassio(hass: HomeAssistant) -> None: +async def test_discovery_hassio(hass: HomeAssistant, mock_dashboard) -> None: """Test dashboard discovery.""" result = await hass.config_entries.flow.async_init( "esphome", From eae12bd48d7c3dd7fe182dbbc0b1ddec1b525c53 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 1 Mar 2023 16:16:04 +0100 Subject: [PATCH 0164/1058] Motion Blinds DHCP restrict (#88919) Co-authored-by: J. Nick Koston --- .../components/motion_blinds/config_flow.py | 12 ++++- .../components/motion_blinds/strings.json | 3 +- .../motion_blinds/test_config_flow.py | 46 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index d861c989ee0e..d93e00913694 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from motionblinds import MotionDiscovery +from motionblinds import MotionDiscovery, MotionGateway import voluptuous as vol from homeassistant import config_entries @@ -86,6 +86,16 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + gateway = MotionGateway(ip=discovery_info.ip, key="abcd1234-56ef-78") + try: + # key not needed for GetDeviceList request + await self.hass.async_add_executor_job(gateway.GetDeviceList) + except Exception: # pylint: disable=broad-except + return self.async_abort(reason="not_motionblinds") + + if not gateway.available: + return self.async_abort(reason="not_motionblinds") + short_mac = mac_address[-6:].upper() self.context["title_placeholders"] = { "short_mac": short_mac, diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index 0b1482883aa0..47c0867187e8 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -28,7 +28,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "not_motionblinds": "Discovered device is not a Motion gateway" } }, "options": { diff --git a/tests/components/motion_blinds/test_config_flow.py b/tests/components/motion_blinds/test_config_flow.py index 5c95b4abd180..ceb20279c1f1 100644 --- a/tests/components/motion_blinds/test_config_flow.py +++ b/tests/components/motion_blinds/test_config_flow.py @@ -89,6 +89,12 @@ def motion_blinds_connect_fixture(mock_get_source_ip): ), patch( "homeassistant.components.motion_blinds.config_flow.MotionDiscovery.discover", return_value=TEST_DISCOVERY_1, + ), patch( + "homeassistant.components.motion_blinds.config_flow.MotionGateway.GetDeviceList", + return_value=True, + ), patch( + "homeassistant.components.motion_blinds.config_flow.MotionGateway.available", + True, ), patch( "homeassistant.components.motion_blinds.gateway.AsyncMotionMulticast.Start_listen", return_value=True, @@ -355,6 +361,46 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: } +async def test_dhcp_flow_abort(hass: HomeAssistant) -> None: + """Test that DHCP discovery aborts if not Motion Blinds.""" + dhcp_data = dhcp.DhcpServiceInfo( + ip=TEST_HOST, + hostname="MOTION_abcdef", + macaddress=TEST_MAC, + ) + + with patch( + "homeassistant.components.motion_blinds.config_flow.MotionGateway.GetDeviceList", + side_effect=socket.timeout, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_motionblinds" + + +async def test_dhcp_flow_abort_invalid_response(hass: HomeAssistant) -> None: + """Test that DHCP discovery aborts if device responded with invalid data.""" + dhcp_data = dhcp.DhcpServiceInfo( + ip=TEST_HOST, + hostname="MOTION_abcdef", + macaddress=TEST_MAC, + ) + + with patch( + "homeassistant.components.motion_blinds.config_flow.MotionGateway.available", + False, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_motionblinds" + + async def test_options_flow(hass: HomeAssistant) -> None: """Test specifying non default settings using options flow.""" config_entry = MockConfigEntry( From 341d046ba79b675b688346b759f933c16db26f1b Mon Sep 17 00:00:00 2001 From: Mitch Date: Wed, 1 Mar 2023 16:17:55 +0100 Subject: [PATCH 0165/1058] Bump requests to 2.28.2 (#88956) Co-authored-by: Martin Hjelmare --- 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 5d05bec39c42..b8f1d3f5a73f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ pyserial==3.5 python-slugify==4.0.1 pyudev==0.23.2 pyyaml==6.0 -requests==2.28.1 +requests==2.28.2 scapy==2.5.0 sqlalchemy==2.0.4 typing-extensions>=4.5.0,<5.0 diff --git a/pyproject.toml b/pyproject.toml index 7a0aff9ce9a6..2e0c0d9ebc38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "pip>=21.0,<23.1", "python-slugify==4.0.1", "pyyaml==6.0", - "requests==2.28.1", + "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", "ulid-transform==0.3.1", "voluptuous==0.13.1", diff --git a/requirements.txt b/requirements.txt index 31831a93e2a8..d7d65f10b687 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ orjson==3.8.6 pip>=21.0,<23.1 python-slugify==4.0.1 pyyaml==6.0 -requests==2.28.1 +requests==2.28.2 typing-extensions>=4.5.0,<5.0 ulid-transform==0.3.1 voluptuous==0.13.1 From 54de16875d2a4475738de2acd4610ef8efd0506c Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 1 Mar 2023 10:19:46 -0500 Subject: [PATCH 0166/1058] Bump Aiosomecomfort to 0.0.11 (#88970) --- homeassistant/components/honeywell/climate.py | 5 ++++- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 3677c0f8d560..9184b8c3d667 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -421,6 +421,7 @@ class HoneywellUSThermostat(ClimateEntity): """Get the latest state from the service.""" try: await self._device.refresh() + self._attr_available = True except ( aiosomecomfort.SomeComfortError, OSError, @@ -428,8 +429,10 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._data.client.login() - except aiosomecomfort.SomeComfortError: + except aiosomecomfort.AuthError: self._attr_available = False await self.hass.async_create_task( self.hass.config_entries.async_reload(self._data.entry_id) ) + except aiosomecomfort.SomeComfortError: + self._attr_available = False diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 4b8e73e9fe72..989e60574900 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.10"] + "requirements": ["aiosomecomfort==0.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 07e0316ab8f8..13818635d9c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.10 +aiosomecomfort==0.0.11 # homeassistant.components.steamist aiosteamist==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1cf8432506f..9e2eab174396 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -254,7 +254,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.10 +aiosomecomfort==0.0.11 # homeassistant.components.steamist aiosteamist==0.3.2 From 09f1c2318d44c1346fdeb5d4b7fd6be0af79da2b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 16:21:11 +0100 Subject: [PATCH 0167/1058] Disable gc in-between energy sensor tests (#88593) --- tests/components/energy/test_sensor.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 3a47423c21f0..538cc9491847 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -1,7 +1,6 @@ """Test the Energy sensors.""" import copy from datetime import timedelta -import gc from typing import Any from unittest.mock import patch @@ -33,18 +32,6 @@ from tests.components.recorder.common import async_wait_recording_done from tests.typing import WebSocketGenerator -@pytest.fixture(autouse=True) -def garbage_collection(): - """Make sure garbage collection is run between all tests. - - There are unknown issues with GC triggering during a test - case, leading to the test breaking down. Make sure we - clean up between each testcase to avoid this issue. - """ - yield - gc.collect() - - @pytest.fixture async def setup_integration(recorder_mock): """Set up the integration.""" From d65dff3f9e923fc0bbd1fc76aaa90b7168dd68a6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:23:36 +0100 Subject: [PATCH 0168/1058] Adjust entity registry access in tests (2) (#88960) --- .../google_assistant/test_smart_home.py | 31 +-- tests/components/hue/test_logbook.py | 11 +- tests/components/knx/test_climate.py | 9 +- .../kostal_plenticore/test_select.py | 20 +- .../landisgyr_heat_meter/test_sensor.py | 13 +- tests/components/litterrobot/test_select.py | 9 +- tests/components/litterrobot/test_switch.py | 9 +- tests/components/logbook/test_init.py | 12 +- .../components/logbook/test_websocket_api.py | 56 +++-- tests/components/melnor/test_sensor.py | 11 +- tests/components/powerwall/test_switch.py | 7 +- tests/components/prometheus/test_init.py | 209 +++++++++++------- .../components/qnap_qsw/test_binary_sensor.py | 11 +- .../test_binary_sensor.py | 9 +- .../rituals_perfume_genie/test_number.py | 10 +- .../rituals_perfume_genie/test_select.py | 10 +- .../rituals_perfume_genie/test_sensor.py | 15 +- .../rituals_perfume_genie/test_switch.py | 10 +- tests/components/snooz/test_fan.py | 28 ++- tests/components/todoist/test_calendar.py | 14 +- 20 files changed, 291 insertions(+), 213 deletions(-) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index e336473f8f41..cf83e47b3bf3 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,5 +1,6 @@ """Test Google Smart Home.""" import asyncio +from types import SimpleNamespace from unittest.mock import ANY, call, patch import pytest @@ -23,30 +24,32 @@ from homeassistant.components.google_assistant import ( from homeassistant.config import async_process_ha_core_config from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature, __version__ from homeassistant.core import EVENT_CALL_SERVICE, HomeAssistant, State -from homeassistant.helpers import device_registry, entity_platform +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_platform, + entity_registry as er, +) from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig -from tests.common import ( - async_capture_events, - mock_area_registry, - mock_device_registry, - mock_registry, -) +from tests.common import async_capture_events REQ_ID = "ff36a3cc-ec34-11e6-b1a0-64510650abcf" @pytest.fixture -def registries(hass): +def registries( + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, +) -> SimpleNamespace: """Registry mock setup.""" - from types import SimpleNamespace - ret = SimpleNamespace() - ret.entity = mock_registry(hass) - ret.device = mock_device_registry(hass) - ret.area = mock_area_registry(hass) + ret.entity = entity_registry + ret.device = device_registry + ret.area = area_registry return ret @@ -238,7 +241,7 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> manufacturer="Someone", model="Some model", sw_version="Some Version", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) registries.device.async_update_device( device.id, area_id=area.id if area_on_device else None diff --git a/tests/components/hue/test_logbook.py b/tests/components/hue/test_logbook.py index c3d87660233a..3f49efcdeb74 100644 --- a/tests/components/hue/test_logbook.py +++ b/tests/components/hue/test_logbook.py @@ -10,7 +10,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .conftest import setup_platform @@ -35,7 +35,9 @@ SAMPLE_V2_EVENT = { } -async def test_humanify_hue_events(hass: HomeAssistant, mock_bridge_v2) -> None: +async def test_humanify_hue_events( + hass: HomeAssistant, mock_bridge_v2, device_registry: dr.DeviceRegistry +) -> None: """Test hue events when the devices are present in the registry.""" await setup_platform(hass, mock_bridge_v2, "sensor") hass.config.components.add("recorder") @@ -43,11 +45,10 @@ async def test_humanify_hue_events(hass: HomeAssistant, mock_bridge_v2) -> None: await hass.async_block_till_done() entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - dev_reg = device_registry.async_get(hass) - v1_device = dev_reg.async_get_or_create( + v1_device = device_registry.async_get_or_create( identifiers={(DOMAIN, "v1")}, name="Remote 1", config_entry_id=entry.entry_id ) - v2_device = dev_reg.async_get_or_create( + v2_device = device_registry.async_get_or_create( identifiers={(DOMAIN, "v2")}, name="Remote 2", config_entry_id=entry.entry_id ) diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 477db04b560f..e10ac76cb404 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -3,7 +3,7 @@ from homeassistant.components.climate import PRESET_ECO, PRESET_SLEEP, HVACMode from homeassistant.components.knx.schema import ClimateSchema from homeassistant.const import CONF_NAME, STATE_IDLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .conftest import KNXTestKit @@ -99,7 +99,9 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write("1/2/6", (0x01,)) -async def test_climate_preset_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: +async def test_climate_preset_mode( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: """Test KNX climate preset mode.""" events = async_capture_events(hass, "state_changed") await knx.setup_integration( @@ -155,8 +157,7 @@ async def test_climate_preset_mode(hass: HomeAssistant, knx: KNXTestKit) -> None assert len(knx.xknx.devices[0].device_updated_cbs) == 2 assert len(knx.xknx.devices[1].device_updated_cbs) == 2 # test removing also removes hooks - er = entity_registry.async_get(hass) - er.async_remove("climate.test") + entity_registry.async_remove("climate.test") await hass.async_block_till_done() # If we remove the entity the underlying devices should disappear too diff --git a/tests/components/kostal_plenticore/test_select.py b/tests/components/kostal_plenticore/test_select.py index b892c0a457a4..682e8f72ac87 100644 --- a/tests/components/kostal_plenticore/test_select.py +++ b/tests/components/kostal_plenticore/test_select.py @@ -3,13 +3,16 @@ from pykoplenti import SettingsData from homeassistant.components.kostal_plenticore.helper import Plenticore from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry async def test_select_battery_charging_usage_available( - hass: HomeAssistant, mock_plenticore: Plenticore, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_plenticore: Plenticore, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test that the battery charging usage select entity is added if the settings are available.""" @@ -25,13 +28,14 @@ async def test_select_battery_charging_usage_available( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entity_registry.async_get(hass).async_is_registered( - "select.battery_charging_usage_mode" - ) + assert entity_registry.async_is_registered("select.battery_charging_usage_mode") async def test_select_battery_charging_usage_not_available( - hass: HomeAssistant, mock_plenticore: Plenticore, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_plenticore: Plenticore, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test that the battery charging usage select entity is not added if the settings are unavailable.""" @@ -40,6 +44,4 @@ async def test_select_battery_charging_usage_not_available( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert not entity_registry.async_get(hass).async_is_registered( - "select.battery_charging_usage_mode" - ) + assert not entity_registry.async_is_registered("select.battery_charging_usage_mode") diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 1c68ceef77cf..9a94491a94fe 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -43,7 +43,9 @@ class MockHeatMeterResponse: @patch("homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService") -async def test_create_sensors(mock_heat_meter, hass: HomeAssistant) -> None: +async def test_create_sensors( + mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test sensor.""" entry_data = { "device": "/dev/USB0", @@ -77,7 +79,6 @@ async def test_create_sensors(mock_heat_meter, hass: HomeAssistant) -> None: # check if 26 attributes have been created assert len(hass.states.async_all()) == 27 - entity_reg = entity_registry.async_get(hass) state = hass.states.get("sensor.heat_meter_heat_usage") assert state @@ -96,14 +97,16 @@ async def test_create_sensors(mock_heat_meter, hass: HomeAssistant) -> None: assert state assert state.state == "devicenr_789" assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_reg.async_get("sensor.heat_meter_device_number") + entity_registry_entry = entity_registry.async_get("sensor.heat_meter_device_number") assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC state = hass.states.get("sensor.heat_meter_meter_date_time") assert state assert state.attributes.get(ATTR_ICON) == "mdi:clock-outline" assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_reg.async_get("sensor.heat_meter_meter_date_time") + entity_registry_entry = entity_registry.async_get( + "sensor.heat_meter_meter_date_time" + ) assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index 7cfa7d221f59..478d801e4dd3 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -9,22 +9,23 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .conftest import setup_integration SELECT_ENTITY_ID = "select.test_clean_cycle_wait_time_minutes" -async def test_wait_time_select(hass: HomeAssistant, mock_account) -> None: +async def test_wait_time_select( + hass: HomeAssistant, mock_account, entity_registry: er.EntityRegistry +) -> None: """Tests the wait time select entity.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) select = hass.states.get(SELECT_ENTITY_ID) assert select - ent_reg = entity_registry.async_get(hass) - entity_entry = ent_reg.async_get(SELECT_ENTITY_ID) + entity_entry = entity_registry.async_get(SELECT_ENTITY_ID) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index a8ae2a38ee0d..eee06101cf3a 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .conftest import setup_integration @@ -19,7 +19,9 @@ NIGHT_LIGHT_MODE_ENTITY_ID = "switch.test_night_light_mode" PANEL_LOCKOUT_ENTITY_ID = "switch.test_panel_lockout" -async def test_switch(hass: HomeAssistant, mock_account: MagicMock) -> None: +async def test_switch( + hass: HomeAssistant, mock_account: MagicMock, entity_registry: er.EntityRegistry +) -> None: """Tests the switch entity was set up.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) @@ -27,8 +29,7 @@ async def test_switch(hass: HomeAssistant, mock_account: MagicMock) -> None: assert state assert state.state == STATE_ON - ent_reg = entity_registry.async_get(hass) - entity_entry = ent_reg.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) + entity_entry = entity_registry.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) assert entity_entry assert entity_entry.entity_category is EntityCategory.CONFIG diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index f98d38cec34a..bb83c1fdb5cd 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -41,7 +41,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers import device_registry, entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component @@ -2586,7 +2586,10 @@ async def test_get_events_invalid_filters( async def test_get_events_with_device_ids( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test logbook get_events for device ids.""" now = dt_util.utcnow() @@ -2599,10 +2602,9 @@ async def test_get_events_with_device_ids( entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_hass(hass) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, sw_version="sw-version", name="device name", diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index b8ea095d8d04..6b21c66de8c1 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -31,7 +31,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import Event, HomeAssistant, State -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -86,12 +86,13 @@ async def _async_mock_logbook_platform(hass: HomeAssistant) -> None: await logbook._process_logbook_platform(hass, "test", MockLogbookPlatform) -async def _async_mock_entity_with_logbook_platform(hass): +async def _async_mock_entity_with_logbook_platform( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> er.RegistryEntry: """Mock an integration that provides an entity that are described by the logbook.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_hass(hass) - ent_reg = entity_registry.async_get(hass) - entry = ent_reg.async_get_or_create( + entry = entity_registry.async_get_or_create( platform="test", domain="sensor", config_entry=entry, @@ -102,14 +103,15 @@ async def _async_mock_entity_with_logbook_platform(hass): return entry -async def _async_mock_devices_with_logbook_platform(hass): +async def _async_mock_devices_with_logbook_platform( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> list[dr.DeviceEntry]: """Mock an integration that provides a device that are described by the logbook.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_hass(hass) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("bridgeid", "0123")}, sw_version="sw-version", name="device name", @@ -117,9 +119,9 @@ async def _async_mock_devices_with_logbook_platform(hass): model="model", suggested_area="Game Room", ) - device2 = dev_reg.async_get_or_create( + device2 = device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:CC")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:CC")}, identifiers={("bridgeid", "4567")}, sw_version="sw-version", name="device name", @@ -413,7 +415,10 @@ async def test_get_events_invalid_filters( async def test_get_events_with_device_ids( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test logbook get_events for device ids.""" now = dt_util.utcnow() @@ -424,7 +429,7 @@ async def test_get_events_with_device_ids( ] ) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] device2 = devices[1] @@ -1797,7 +1802,10 @@ async def test_subscribe_unsubscribe_logbook_stream_big_query( @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream_device( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test subscribe/unsubscribe logbook stream with a device.""" now = dt_util.utcnow() @@ -1807,7 +1815,7 @@ async def test_subscribe_unsubscribe_logbook_stream_device( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] device2 = devices[1] @@ -1923,7 +1931,10 @@ async def test_event_stream_bad_start_time( @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_logbook_stream_match_multiple_entities( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, ) -> None: """Test logbook stream with a described integration that uses multiple entities.""" now = dt_util.utcnow() @@ -1933,7 +1944,7 @@ async def test_logbook_stream_match_multiple_entities( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - entry = await _async_mock_entity_with_logbook_platform(hass) + entry = await _async_mock_entity_with_logbook_platform(hass, entity_registry) entity_id = entry.entity_id hass.states.async_set(entity_id, STATE_ON) @@ -2066,6 +2077,7 @@ async def test_live_stream_with_one_second_commit_interval( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test the recorder with a 1s commit interval.""" config = {recorder.CONF_COMMIT_INTERVAL: 0.5} @@ -2077,7 +2089,7 @@ async def test_live_stream_with_one_second_commit_interval( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] await hass.async_block_till_done() @@ -2281,6 +2293,7 @@ async def test_recorder_is_far_behind( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, ) -> None: """Test we still start live streaming if the recorder is far behind.""" now = dt_util.utcnow() @@ -2291,7 +2304,7 @@ async def test_recorder_is_far_behind( ] ) await async_wait_recording_done(hass) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] await async_wait_recording_done(hass) @@ -2705,7 +2718,10 @@ async def test_logbook_stream_ignores_forced_updates( @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_all_entities_are_continuous_with_device( - recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, ) -> None: """Test subscribe/unsubscribe logbook stream with entities that are always filtered and a device.""" now = dt_util.utcnow() @@ -2716,7 +2732,7 @@ async def test_subscribe_all_entities_are_continuous_with_device( ] ) await async_wait_recording_done(hass) - devices = await _async_mock_devices_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass, device_registry) device = devices[0] device2 = devices[1] diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index d51a600492b5..b525ec67b17f 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -6,7 +6,7 @@ from freezegun import freeze_time from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .conftest import ( @@ -72,7 +72,9 @@ async def test_minutes_remaining_sensor(hass: HomeAssistant) -> None: assert minutes_remaining_sensor.state == end_time.isoformat(timespec="seconds") -async def test_rssi_sensor(hass: HomeAssistant) -> None: +async def test_rssi_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the rssi sensor.""" entry = mock_config_entry(hass) @@ -88,15 +90,14 @@ async def test_rssi_sensor(hass: HomeAssistant) -> None: entity_id = f"sensor.{device.name}_rssi" # Ensure the entity is disabled by default by checking the registry - ent_registry = entity_registry.async_get(hass) - rssi_registry_entry = ent_registry.async_get(entity_id) + rssi_registry_entry = entity_registry.async_get(entity_id) assert rssi_registry_entry is not None assert rssi_registry_entry.disabled_by is not None # Enable the entity and assert everything else is working as expected - ent_registry.async_update_entity(entity_id, disabled_by=None) + entity_registry.async_update_entity(entity_id, disabled_by=None) await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/powerwall/test_switch.py b/tests/components/powerwall/test_switch.py index 0d01541bf6b8..393f89e62fd6 100644 --- a/tests/components/powerwall/test_switch.py +++ b/tests/components/powerwall/test_switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( from homeassistant.const import ATTR_ENTITY_ID, CONF_IP_ADDRESS, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as ent_reg +from homeassistant.helpers import entity_registry as er from .mocks import _mock_powerwall_with_fixtures @@ -38,11 +38,12 @@ async def mock_powerwall_fixture(hass): yield mock_powerwall -async def test_entity_registry(hass: HomeAssistant, mock_powerwall) -> None: +async def test_entity_registry( + hass: HomeAssistant, mock_powerwall, entity_registry: er.EntityRegistry +) -> None: """Test powerwall off-grid switch device.""" mock_powerwall.get_grid_status = Mock(return_value=GridStatus.CONNECTED) - entity_registry = ent_reg.async_get(hass) assert ENTITY_ID in entity_registry.entities diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index fc325663f5c5..e328487fa752 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import datetime from http import HTTPStatus +from typing import Any from unittest import mock import prometheus_client @@ -58,7 +59,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -522,7 +523,11 @@ async def test_counter(client, counter_entities) -> None: @pytest.mark.parametrize("namespace", [""]) async def test_renaming_entity_name( - hass: HomeAssistant, registry, client, sensor_entities, climate_entities + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + sensor_entities, + climate_entities, ) -> None: """Test renaming entity name.""" data = {**sensor_entities, **climate_entities} @@ -566,9 +571,9 @@ async def test_renaming_entity_name( 'friendly_name="HeatPump"} 0.0' in body ) - assert "sensor.outside_temperature" in registry.entities - assert "climate.heatpump" in registry.entities - registry.async_update_entity( + assert "sensor.outside_temperature" in entity_registry.entities + assert "climate.heatpump" in entity_registry.entities + entity_registry.async_update_entity( entity_id=data["sensor_1"].entity_id, name="Outside Temperature Renamed", ) @@ -578,7 +583,7 @@ async def test_renaming_entity_name( 15.6, {ATTR_FRIENDLY_NAME: "Outside Temperature Renamed"}, ) - registry.async_update_entity( + entity_registry.async_update_entity( entity_id=data["climate_1"].entity_id, name="HeatPump Renamed", ) @@ -644,7 +649,11 @@ async def test_renaming_entity_name( @pytest.mark.parametrize("namespace", [""]) async def test_renaming_entity_id( - hass: HomeAssistant, registry, client, sensor_entities, climate_entities + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + sensor_entities, + climate_entities, ) -> None: """Test renaming entity id.""" data = {**sensor_entities, **climate_entities} @@ -674,9 +683,9 @@ async def test_renaming_entity_id( 'friendly_name="Outside Humidity"} 1.0' in body ) - assert "sensor.outside_temperature" in registry.entities - assert "climate.heatpump" in registry.entities - registry.async_update_entity( + assert "sensor.outside_temperature" in entity_registry.entities + assert "climate.heatpump" in entity_registry.entities + entity_registry.async_update_entity( entity_id="sensor.outside_temperature", new_entity_id="sensor.outside_temperature_renamed", ) @@ -720,7 +729,11 @@ async def test_renaming_entity_id( @pytest.mark.parametrize("namespace", [""]) async def test_deleting_entity( - hass: HomeAssistant, registry, client, sensor_entities, climate_entities + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + sensor_entities, + climate_entities, ) -> None: """Test deleting a entity.""" data = {**sensor_entities, **climate_entities} @@ -764,10 +777,10 @@ async def test_deleting_entity( 'friendly_name="HeatPump"} 0.0' in body ) - assert "sensor.outside_temperature" in registry.entities - assert "climate.heatpump" in registry.entities - registry.async_remove(data["sensor_1"].entity_id) - registry.async_remove(data["climate_1"].entity_id) + assert "sensor.outside_temperature" in entity_registry.entities + assert "climate.heatpump" in entity_registry.entities + entity_registry.async_remove(data["sensor_1"].entity_id) + entity_registry.async_remove(data["climate_1"].entity_id) await hass.async_block_till_done() body = await generate_latest_metrics(client) @@ -795,7 +808,11 @@ async def test_deleting_entity( @pytest.mark.parametrize("namespace", [""]) async def test_disabling_entity( - hass: HomeAssistant, registry, client, sensor_entities, climate_entities + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client, + sensor_entities, + climate_entities, ) -> None: """Test disabling a entity.""" data = {**sensor_entities, **climate_entities} @@ -848,15 +865,15 @@ async def test_disabling_entity( 'friendly_name="HeatPump"} 0.0' in body ) - assert "sensor.outside_temperature" in registry.entities - assert "climate.heatpump" in registry.entities - registry.async_update_entity( + assert "sensor.outside_temperature" in entity_registry.entities + assert "climate.heatpump" in entity_registry.entities + entity_registry.async_update_entity( entity_id=data["sensor_1"].entity_id, - disabled_by=entity_registry.RegistryEntryDisabler.USER, + disabled_by=er.RegistryEntryDisabler.USER, ) - registry.async_update_entity( + entity_registry.async_update_entity( entity_id="climate.heatpump", - disabled_by=entity_registry.RegistryEntryDisabler.USER, + disabled_by=er.RegistryEntryDisabler.USER, ) await hass.async_block_till_done() @@ -883,17 +900,13 @@ async def test_disabling_entity( ) -@pytest.fixture(name="registry") -def entity_registry_fixture(hass): - """Provide entity registry.""" - return entity_registry.async_get(hass) - - @pytest.fixture(name="sensor_entities") -async def sensor_fixture(hass, registry): +async def sensor_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate sensor entities.""" data = {} - sensor_1 = registry.async_get_or_create( + sensor_1 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_1", @@ -907,7 +920,7 @@ async def sensor_fixture(hass, registry): data["sensor_1"] = sensor_1 data["sensor_1_attributes"] = sensor_1_attributes - sensor_2 = registry.async_get_or_create( + sensor_2 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_2", @@ -919,7 +932,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_2, 54.0) data["sensor_2"] = sensor_2 - sensor_3 = registry.async_get_or_create( + sensor_3 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_3", @@ -935,7 +948,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_3, 14) data["sensor_3"] = sensor_3 - sensor_4 = registry.async_get_or_create( + sensor_4 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_4", @@ -946,7 +959,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_4, 74) data["sensor_4"] = sensor_4 - sensor_5 = registry.async_get_or_create( + sensor_5 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_5", @@ -957,7 +970,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_5, 0.123) data["sensor_5"] = sensor_5 - sensor_6 = registry.async_get_or_create( + sensor_6 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_6", @@ -968,7 +981,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_6, 25) data["sensor_6"] = sensor_6 - sensor_7 = registry.async_get_or_create( + sensor_7 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_7", @@ -979,7 +992,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_7, 3.7069) data["sensor_7"] = sensor_7 - sensor_8 = registry.async_get_or_create( + sensor_8 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_8", @@ -989,7 +1002,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_8, 0.002) data["sensor_8"] = sensor_8 - sensor_9 = registry.async_get_or_create( + sensor_9 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_9", @@ -999,7 +1012,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_9, "should_not_work") data["sensor_9"] = sensor_9 - sensor_10 = registry.async_get_or_create( + sensor_10 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_10", @@ -1010,7 +1023,7 @@ async def sensor_fixture(hass, registry): set_state_with_entry(hass, sensor_10, "should_not_work") data["sensor_10"] = sensor_10 - sensor_11 = registry.async_get_or_create( + sensor_11 = entity_registry.async_get_or_create( domain=sensor.DOMAIN, platform="test", unique_id="sensor_11", @@ -1027,10 +1040,12 @@ async def sensor_fixture(hass, registry): @pytest.fixture(name="climate_entities") -async def climate_fixture(hass, registry): +async def climate_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry | dict[str, Any]]: """Simulate climate entities.""" data = {} - climate_1 = registry.async_get_or_create( + climate_1 = entity_registry.async_get_or_create( domain=climate.DOMAIN, platform="test", unique_id="climate_1", @@ -1049,7 +1064,7 @@ async def climate_fixture(hass, registry): data["climate_1"] = climate_1 data["climate_1_attributes"] = climate_1_attributes - climate_2 = registry.async_get_or_create( + climate_2 = entity_registry.async_get_or_create( domain=climate.DOMAIN, platform="test", unique_id="climate_2", @@ -1070,7 +1085,7 @@ async def climate_fixture(hass, registry): data["climate_2"] = climate_2 data["climate_2_attributes"] = climate_2_attributes - climate_3 = registry.async_get_or_create( + climate_3 = entity_registry.async_get_or_create( domain=climate.DOMAIN, platform="test", unique_id="climate_3", @@ -1092,10 +1107,12 @@ async def climate_fixture(hass, registry): @pytest.fixture(name="humidifier_entities") -async def humidifier_fixture(hass, registry): +async def humidifier_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry | dict[str, Any]]: """Simulate humidifier entities.""" data = {} - humidifier_1 = registry.async_get_or_create( + humidifier_1 = entity_registry.async_get_or_create( domain=humidifier.DOMAIN, platform="test", unique_id="humidifier_1", @@ -1110,7 +1127,7 @@ async def humidifier_fixture(hass, registry): data["humidifier_1"] = humidifier_1 data["humidifier_1_attributes"] = humidifier_1_attributes - humidifier_2 = registry.async_get_or_create( + humidifier_2 = entity_registry.async_get_or_create( domain=humidifier.DOMAIN, platform="test", unique_id="humidifier_2", @@ -1125,7 +1142,7 @@ async def humidifier_fixture(hass, registry): data["humidifier_2"] = humidifier_2 data["humidifier_2_attributes"] = humidifier_2_attributes - humidifier_3 = registry.async_get_or_create( + humidifier_3 = entity_registry.async_get_or_create( domain=humidifier.DOMAIN, platform="test", unique_id="humidifier_3", @@ -1146,10 +1163,12 @@ async def humidifier_fixture(hass, registry): @pytest.fixture(name="lock_entities") -async def lock_fixture(hass, registry): +async def lock_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate lock entities.""" data = {} - lock_1 = registry.async_get_or_create( + lock_1 = entity_registry.async_get_or_create( domain=lock.DOMAIN, platform="test", unique_id="lock_1", @@ -1159,7 +1178,7 @@ async def lock_fixture(hass, registry): set_state_with_entry(hass, lock_1, STATE_LOCKED) data["lock_1"] = lock_1 - lock_2 = registry.async_get_or_create( + lock_2 = entity_registry.async_get_or_create( domain=lock.DOMAIN, platform="test", unique_id="lock_2", @@ -1174,10 +1193,12 @@ async def lock_fixture(hass, registry): @pytest.fixture(name="cover_entities") -async def cover_fixture(hass, registry): +async def cover_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate cover entities.""" data = {} - cover_open = registry.async_get_or_create( + cover_open = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_open", @@ -1187,7 +1208,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_open, STATE_OPEN) data["cover_open"] = cover_open - cover_closed = registry.async_get_or_create( + cover_closed = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_closed", @@ -1197,7 +1218,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_closed, STATE_CLOSED) data["cover_closed"] = cover_closed - cover_closing = registry.async_get_or_create( + cover_closing = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_closing", @@ -1207,7 +1228,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_closing, STATE_CLOSING) data["cover_closing"] = cover_closing - cover_opening = registry.async_get_or_create( + cover_opening = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_opening", @@ -1217,7 +1238,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_opening, STATE_OPENING) data["cover_opening"] = cover_opening - cover_position = registry.async_get_or_create( + cover_position = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_position", @@ -1228,7 +1249,7 @@ async def cover_fixture(hass, registry): set_state_with_entry(hass, cover_position, STATE_OPEN, cover_position_attributes) data["cover_position"] = cover_position - cover_tilt_position = registry.async_get_or_create( + cover_tilt_position = entity_registry.async_get_or_create( domain=cover.DOMAIN, platform="test", unique_id="cover_tilt_position", @@ -1246,10 +1267,12 @@ async def cover_fixture(hass, registry): @pytest.fixture(name="input_number_entities") -async def input_number_fixture(hass, registry): +async def input_number_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate input_number entities.""" data = {} - input_number_1 = registry.async_get_or_create( + input_number_1 = entity_registry.async_get_or_create( domain=input_number.DOMAIN, platform="test", unique_id="input_number_1", @@ -1259,7 +1282,7 @@ async def input_number_fixture(hass, registry): set_state_with_entry(hass, input_number_1, 5.2) data["input_number_1"] = input_number_1 - input_number_2 = registry.async_get_or_create( + input_number_2 = entity_registry.async_get_or_create( domain=input_number.DOMAIN, platform="test", unique_id="input_number_2", @@ -1268,7 +1291,7 @@ async def input_number_fixture(hass, registry): set_state_with_entry(hass, input_number_2, 60) data["input_number_2"] = input_number_2 - input_number_3 = registry.async_get_or_create( + input_number_3 = entity_registry.async_get_or_create( domain=input_number.DOMAIN, platform="test", unique_id="input_number_3", @@ -1284,10 +1307,12 @@ async def input_number_fixture(hass, registry): @pytest.fixture(name="input_boolean_entities") -async def input_boolean_fixture(hass, registry): +async def input_boolean_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate input_boolean entities.""" data = {} - input_boolean_1 = registry.async_get_or_create( + input_boolean_1 = entity_registry.async_get_or_create( domain=input_boolean.DOMAIN, platform="test", unique_id="input_boolean_1", @@ -1297,7 +1322,7 @@ async def input_boolean_fixture(hass, registry): set_state_with_entry(hass, input_boolean_1, STATE_ON) data["input_boolean_1"] = input_boolean_1 - input_boolean_2 = registry.async_get_or_create( + input_boolean_2 = entity_registry.async_get_or_create( domain=input_boolean.DOMAIN, platform="test", unique_id="input_boolean_2", @@ -1312,10 +1337,12 @@ async def input_boolean_fixture(hass, registry): @pytest.fixture(name="binary_sensor_entities") -async def binary_sensor_fixture(hass, registry): +async def binary_sensor_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate binary_sensor entities.""" data = {} - binary_sensor_1 = registry.async_get_or_create( + binary_sensor_1 = entity_registry.async_get_or_create( domain=binary_sensor.DOMAIN, platform="test", unique_id="binary_sensor_1", @@ -1325,7 +1352,7 @@ async def binary_sensor_fixture(hass, registry): set_state_with_entry(hass, binary_sensor_1, STATE_ON) data["binary_sensor_1"] = binary_sensor_1 - binary_sensor_2 = registry.async_get_or_create( + binary_sensor_2 = entity_registry.async_get_or_create( domain=binary_sensor.DOMAIN, platform="test", unique_id="binary_sensor_2", @@ -1340,10 +1367,12 @@ async def binary_sensor_fixture(hass, registry): @pytest.fixture(name="light_entities") -async def light_fixture(hass, registry): +async def light_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate light entities.""" data = {} - light_1 = registry.async_get_or_create( + light_1 = entity_registry.async_get_or_create( domain=light.DOMAIN, platform="test", unique_id="light_1", @@ -1353,7 +1382,7 @@ async def light_fixture(hass, registry): set_state_with_entry(hass, light_1, STATE_ON) data["light_1"] = light_1 - light_2 = registry.async_get_or_create( + light_2 = entity_registry.async_get_or_create( domain=light.DOMAIN, platform="test", unique_id="light_2", @@ -1363,7 +1392,7 @@ async def light_fixture(hass, registry): set_state_with_entry(hass, light_2, STATE_OFF) data["light_2"] = light_2 - light_3 = registry.async_get_or_create( + light_3 = entity_registry.async_get_or_create( domain=light.DOMAIN, platform="test", unique_id="light_3", @@ -1375,7 +1404,7 @@ async def light_fixture(hass, registry): data["light_3"] = light_3 data["light_3_attributes"] = light_3_attributes - light_4 = registry.async_get_or_create( + light_4 = entity_registry.async_get_or_create( domain=light.DOMAIN, platform="test", unique_id="light_4", @@ -1392,10 +1421,12 @@ async def light_fixture(hass, registry): @pytest.fixture(name="switch_entities") -async def switch_fixture(hass, registry): +async def switch_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry | dict[str, Any]]: """Simulate switch entities.""" data = {} - switch_1 = registry.async_get_or_create( + switch_1 = entity_registry.async_get_or_create( domain=switch.DOMAIN, platform="test", unique_id="switch_1", @@ -1407,7 +1438,7 @@ async def switch_fixture(hass, registry): data["switch_1"] = switch_1 data["switch_1_attributes"] = switch_1_attributes - switch_2 = registry.async_get_or_create( + switch_2 = entity_registry.async_get_or_create( domain=switch.DOMAIN, platform="test", unique_id="switch_2", @@ -1424,10 +1455,12 @@ async def switch_fixture(hass, registry): @pytest.fixture(name="person_entities") -async def person_fixture(hass, registry): +async def person_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate person entities.""" data = {} - person_1 = registry.async_get_or_create( + person_1 = entity_registry.async_get_or_create( domain=person.DOMAIN, platform="test", unique_id="person_1", @@ -1437,7 +1470,7 @@ async def person_fixture(hass, registry): set_state_with_entry(hass, person_1, STATE_HOME) data["person_1"] = person_1 - person_2 = registry.async_get_or_create( + person_2 = entity_registry.async_get_or_create( domain=person.DOMAIN, platform="test", unique_id="person_2", @@ -1452,10 +1485,12 @@ async def person_fixture(hass, registry): @pytest.fixture(name="device_tracker_entities") -async def device_tracker_fixture(hass, registry): +async def device_tracker_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate device_tracker entities.""" data = {} - device_tracker_1 = registry.async_get_or_create( + device_tracker_1 = entity_registry.async_get_or_create( domain=device_tracker.DOMAIN, platform="test", unique_id="device_tracker_1", @@ -1465,7 +1500,7 @@ async def device_tracker_fixture(hass, registry): set_state_with_entry(hass, device_tracker_1, STATE_HOME) data["device_tracker_1"] = device_tracker_1 - device_tracker_2 = registry.async_get_or_create( + device_tracker_2 = entity_registry.async_get_or_create( domain=device_tracker.DOMAIN, platform="test", unique_id="device_tracker_2", @@ -1480,10 +1515,12 @@ async def device_tracker_fixture(hass, registry): @pytest.fixture(name="counter_entities") -async def counter_fixture(hass, registry): +async def counter_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: """Simulate counter entities.""" data = {} - counter_1 = registry.async_get_or_create( + counter_1 = entity_registry.async_get_or_create( domain=counter.DOMAIN, platform="test", unique_id="counter_1", @@ -1497,8 +1534,8 @@ async def counter_fixture(hass, registry): def set_state_with_entry( - hass, - entry: entity_registry.RegistryEntry, + hass: HomeAssistant, + entry: er.RegistryEntry, state, additional_attributes=None, new_entity_id=None, diff --git a/tests/components/qnap_qsw/test_binary_sensor.py b/tests/components/qnap_qsw/test_binary_sensor.py index a270e78f0518..f007f799349d 100644 --- a/tests/components/qnap_qsw/test_binary_sensor.py +++ b/tests/components/qnap_qsw/test_binary_sensor.py @@ -5,18 +5,19 @@ from unittest.mock import AsyncMock from homeassistant.components.qnap_qsw.const import ATTR_MESSAGE from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .util import async_init_integration async def test_qnap_qsw_create_binary_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: AsyncMock + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + entity_registry: er.EntityRegistry, ) -> None: """Test creation of binary sensors.""" await async_init_integration(hass) - er = entity_registry.async_get(hass) state = hass.states.get("binary_sensor.qsw_m408_4c_anomaly") assert state.state == STATE_OFF @@ -24,7 +25,7 @@ async def test_qnap_qsw_create_binary_sensors( state = hass.states.get("binary_sensor.qsw_m408_4c_lacp_port_1_link") assert state.state == STATE_OFF - entry = er.async_get(state.entity_id) + entry = entity_registry.async_get(state.entity_id) assert entry.unique_id == "qsw_unique_id_ports-status_lacp_port_1_link" state = hass.states.get("binary_sensor.qsw_m408_4c_lacp_port_2_link") @@ -44,7 +45,7 @@ async def test_qnap_qsw_create_binary_sensors( state = hass.states.get("binary_sensor.qsw_m408_4c_port_1_link") assert state.state == STATE_ON - entry = er.async_get(state.entity_id) + entry = entity_registry.async_get(state.entity_id) assert entry.unique_id == "qsw_unique_id_ports-status_port_1_link" state = hass.states.get("binary_sensor.qsw_m408_4c_port_2_link") diff --git a/tests/components/rituals_perfume_genie/test_binary_sensor.py b/tests/components/rituals_perfume_genie/test_binary_sensor.py index 58c68b8a9286..ea4d8021ebaf 100644 --- a/tests/components/rituals_perfume_genie/test_binary_sensor.py +++ b/tests/components/rituals_perfume_genie/test_binary_sensor.py @@ -3,7 +3,7 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.rituals_perfume_genie.binary_sensor import CHARGING_SUFFIX from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .common import ( init_integration, @@ -12,12 +12,13 @@ from .common import ( ) -async def test_binary_sensors(hass: HomeAssistant) -> None: +async def test_binary_sensors( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the Rituals Perfume Genie binary sensor.""" config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) hublot = diffuser.hublot state = hass.states.get("binary_sensor.genie_battery_charging") @@ -27,7 +28,7 @@ async def test_binary_sensors(hass: HomeAssistant) -> None: state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY_CHARGING ) - entry = registry.async_get("binary_sensor.genie_battery_charging") + entry = entity_registry.async_get("binary_sensor.genie_battery_charging") assert entry assert entry.unique_id == f"{hublot}{CHARGING_SUFFIX}" assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/rituals_perfume_genie/test_number.py b/tests/components/rituals_perfume_genie/test_number.py index 3c15352b64bd..028ab40ed76b 100644 --- a/tests/components/rituals_perfume_genie/test_number.py +++ b/tests/components/rituals_perfume_genie/test_number.py @@ -18,7 +18,7 @@ from homeassistant.components.rituals_perfume_genie.number import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import ( @@ -29,14 +29,14 @@ from .common import ( ) -async def test_number_entity(hass: HomeAssistant) -> None: +async def test_number_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the diffuser number entity.""" config_entry = mock_config_entry(unique_id="number_test") diffuser = mock_diffuser(hublot="lot123", perfume_amount=2) await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) - state = hass.states.get("number.genie_perfume_amount") assert state assert state.state == str(diffuser.perfume_amount) @@ -44,7 +44,7 @@ async def test_number_entity(hass: HomeAssistant) -> None: assert state.attributes[ATTR_MIN] == MIN_PERFUME_AMOUNT assert state.attributes[ATTR_MAX] == MAX_PERFUME_AMOUNT - entry = registry.async_get("number.genie_perfume_amount") + entry = entity_registry.async_get("number.genie_perfume_amount") assert entry assert entry.unique_id == f"{diffuser.hublot}{PERFUME_AMOUNT_SUFFIX}" diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index d9b7690106ff..00147b9073c2 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -16,27 +16,27 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import init_integration, mock_config_entry, mock_diffuser -async def test_select_entity(hass: HomeAssistant) -> None: +async def test_select_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and state of the diffuser select entity.""" config_entry = mock_config_entry(unique_id="select_test") diffuser = mock_diffuser(hublot="lot123", room_size_square_meter=60) await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) - state = hass.states.get("select.genie_room_size") assert state assert state.state == str(diffuser.room_size_square_meter) assert state.attributes[ATTR_ICON] == "mdi:ruler-square" assert state.attributes[ATTR_OPTIONS] == ["15", "30", "60", "100"] - entry = registry.async_get("select.genie_room_size") + entry = entity_registry.async_get("select.genie_room_size") assert entry assert entry.unique_id == f"{diffuser.hublot}{ROOM_SIZE_SUFFIX}" assert entry.unit_of_measurement == AREA_SQUARE_METERS diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py index eef92f71a2ef..5573ddc6332d 100644 --- a/tests/components/rituals_perfume_genie/test_sensor.py +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from .common import ( init_integration, @@ -24,12 +24,13 @@ from .common import ( ) -async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> None: +async def test_sensors_diffuser_v1_battery_cartridge( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the Rituals Perfume Genie sensors.""" config_entry = mock_config_entry(unique_id="id_123_sensor_test_diffuser_v1") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) hublot = diffuser.hublot state = hass.states.get("sensor.genie_perfume") @@ -37,7 +38,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non assert state.state == diffuser.perfume assert state.attributes.get(ATTR_ICON) == "mdi:tag-text" - entry = registry.async_get("sensor.genie_perfume") + entry = entity_registry.async_get("sensor.genie_perfume") assert entry assert entry.unique_id == f"{hublot}{PERFUME_SUFFIX}" @@ -46,7 +47,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non assert state.state == diffuser.fill assert state.attributes.get(ATTR_ICON) == "mdi:beaker" - entry = registry.async_get("sensor.genie_fill") + entry = entity_registry.async_get("sensor.genie_fill") assert entry assert entry.unique_id == f"{hublot}{FILL_SUFFIX}" @@ -56,7 +57,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.BATTERY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.genie_battery") + entry = entity_registry.async_get("sensor.genie_battery") assert entry assert entry.unique_id == f"{hublot}{BATTERY_SUFFIX}" assert entry.entity_category == EntityCategory.DIAGNOSTIC @@ -67,7 +68,7 @@ async def test_sensors_diffuser_v1_battery_cartridge(hass: HomeAssistant) -> Non assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = registry.async_get("sensor.genie_wifi") + entry = entity_registry.async_get("sensor.genie_wifi") assert entry assert entry.unique_id == f"{hublot}{WIFI_SUFFIX}" assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py index 960923a1b771..cb688f528f97 100644 --- a/tests/components/rituals_perfume_genie/test_switch.py +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -13,7 +13,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .common import ( @@ -23,20 +23,20 @@ from .common import ( ) -async def test_switch_entity(hass: HomeAssistant) -> None: +async def test_switch_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test the creation and values of the Rituals Perfume Genie diffuser switch.""" config_entry = mock_config_entry(unique_id="id_123_switch_test") diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) - registry = entity_registry.async_get(hass) - state = hass.states.get("switch.genie") assert state assert state.state == STATE_ON assert state.attributes.get(ATTR_ICON) == "mdi:fan" - entry = registry.async_get("switch.genie") + entry = entity_registry.async_get("switch.genie") assert entry assert entry.unique_id == diffuser.hublot diff --git a/tests/components/snooz/test_fan.py b/tests/components/snooz/test_fan.py index 698a839c84bd..cda4ce24db16 100644 --- a/tests/components/snooz/test_fan.py +++ b/tests/components/snooz/test_fan.py @@ -27,7 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry @@ -172,12 +172,14 @@ async def test_push_events( assert state.attributes[ATTR_ASSUMED_STATE] is True -async def test_restore_state(hass: HomeAssistant) -> None: +async def test_restore_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests restoring entity state.""" device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState) entry = await create_mock_snooz_config_entry(hass, device) - entity_id = get_fan_entity_id(hass, device) + entity_id = get_fan_entity_id(hass, device, entity_registry) # call service to store state await hass.services.async_call( @@ -203,12 +205,14 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.attributes[ATTR_ASSUMED_STATE] is True -async def test_restore_unknown_state(hass: HomeAssistant) -> None: +async def test_restore_unknown_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Tests restoring entity state that was unknown.""" device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState) entry = await create_mock_snooz_config_entry(hass, device) - entity_id = get_fan_entity_id(hass, device) + entity_id = get_fan_entity_id(hass, device, entity_registry) # unload entry await hass.config_entries.async_unload(entry.entry_id) @@ -282,16 +286,18 @@ async def test_command_results( @pytest.fixture(name="snooz_fan_entity_id") async def fixture_snooz_fan_entity_id( - hass: HomeAssistant, mock_connected_snooz: SnoozFixture + hass: HomeAssistant, + mock_connected_snooz: SnoozFixture, + entity_registry: er.EntityRegistry, ) -> str: """Mock a Snooz fan entity and config entry.""" - return get_fan_entity_id(hass, mock_connected_snooz.device) + return get_fan_entity_id(hass, mock_connected_snooz.device, entity_registry) -def get_fan_entity_id(hass: HomeAssistant, device: MockSnoozDevice) -> str: +def get_fan_entity_id( + hass: HomeAssistant, device: MockSnoozDevice, entity_registry: er.EntityRegistry +) -> str: """Get the entity ID for a mock device.""" - return entity_registry.async_get(hass).async_get_entity_id( - Platform.FAN, DOMAIN, device.address - ) + return entity_registry.async_get_entity_id(Platform.FAN, DOMAIN, device.address) diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index fece314c91c1..5b5dc817d6d3 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -9,7 +9,7 @@ from homeassistant import setup from homeassistant.components.todoist.calendar import DOMAIN from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity @@ -69,7 +69,9 @@ def mock_api(task) -> AsyncMock: @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") -async def test_calendar_entity_unique_id(todoist_api, hass: HomeAssistant, api) -> None: +async def test_calendar_entity_unique_id( + todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry +) -> None: """Test unique id is set to project id.""" todoist_api.return_value = api assert await setup.async_setup_component( @@ -84,8 +86,7 @@ async def test_calendar_entity_unique_id(todoist_api, hass: HomeAssistant, api) ) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entity = registry.async_get("calendar.name") + entity = entity_registry.async_get("calendar.name") assert entity.unique_id == "12345" @@ -114,7 +115,7 @@ async def test_update_entity_for_custom_project_with_labels_on(todoist_api, hass @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_custom_project_unique_id( - todoist_api, hass: HomeAssistant, api + todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry ) -> None: """Test unique id is None for any custom projects.""" todoist_api.return_value = api @@ -131,8 +132,7 @@ async def test_calendar_custom_project_unique_id( ) await hass.async_block_till_done() - registry = entity_registry.async_get(hass) - entity = registry.async_get("calendar.all_projects") + entity = entity_registry.async_get("calendar.all_projects") assert entity is None state = hass.states.get("calendar.all_projects") From b84eead3f8023e181fef818c6a835780f4908686 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:24:31 +0100 Subject: [PATCH 0169/1058] Adjust entity registry access in helper tests (#88965) --- tests/helpers/test_intent.py | 60 +++++++++++++++++++-------------- tests/helpers/test_service.py | 63 +++++++++++++++++------------------ 2 files changed, 66 insertions(+), 57 deletions(-) diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index 7211f2bb9b4e..edc6a281172e 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -7,10 +7,10 @@ from homeassistant.components.switch import SwitchDeviceClass from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers import ( - area_registry, + area_registry as ar, config_validation as cv, - device_registry, - entity_registry, + device_registry as dr, + entity_registry as er, intent, ) from homeassistant.setup import async_setup_component @@ -24,12 +24,15 @@ class MockIntentHandler(intent.IntentHandler): self.slot_schema = slot_schema -async def test_async_match_states(hass: HomeAssistant) -> None: +async def test_async_match_states( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test async_match_state helper.""" - areas = area_registry.async_get(hass) - area_kitchen = areas.async_get_or_create("kitchen") - areas.async_update(area_kitchen.id, aliases={"food room"}) - area_bedroom = areas.async_get_or_create("bedroom") + area_kitchen = area_registry.async_get_or_create("kitchen") + area_registry.async_update(area_kitchen.id, aliases={"food room"}) + area_bedroom = area_registry.async_get_or_create("bedroom") state1 = State( "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} @@ -39,14 +42,15 @@ async def test_async_match_states(hass: HomeAssistant) -> None: ) # Put entities into different areas - entities = entity_registry.async_get(hass) - entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - entities.async_update_entity(state1.entity_id, area_id=area_kitchen.id) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity(state1.entity_id, area_id=area_kitchen.id) - entities.async_get_or_create( + entity_registry.async_get_or_create( "switch", "demo", "5678", suggested_object_id="bedroom" ) - entities.async_update_entity( + entity_registry.async_update_entity( state2.entity_id, area_id=area_bedroom.id, device_class=SwitchDeviceClass.OUTLET, @@ -102,17 +106,20 @@ async def test_async_match_states(hass: HomeAssistant) -> None: ) == [state2] -async def test_match_device_area(hass: HomeAssistant) -> None: +async def test_match_device_area( + hass: HomeAssistant, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test async_match_state with a device in an area.""" - areas = area_registry.async_get(hass) - area_kitchen = areas.async_get_or_create("kitchen") - area_bedroom = areas.async_get_or_create("bedroom") + area_kitchen = area_registry.async_get_or_create("kitchen") + area_bedroom = area_registry.async_get_or_create("bedroom") - devices = device_registry.async_get(hass) - kitchen_device = devices.async_get_or_create( + kitchen_device = device_registry.async_get_or_create( config_entry_id="1234", connections=set(), identifiers={("demo", "id-1234")} ) - devices.async_update_device(kitchen_device.id, area_id=area_kitchen.id) + device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) state1 = State( "light.kitchen", "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} @@ -123,12 +130,15 @@ async def test_match_device_area(hass: HomeAssistant) -> None: state3 = State( "light.living_room", "on", attributes={ATTR_FRIENDLY_NAME: "living room light"} ) - entities = entity_registry.async_get(hass) - entities.async_get_or_create("light", "demo", "1234", suggested_object_id="kitchen") - entities.async_update_entity(state1.entity_id, device_id=kitchen_device.id) + entity_registry.async_get_or_create( + "light", "demo", "1234", suggested_object_id="kitchen" + ) + entity_registry.async_update_entity(state1.entity_id, device_id=kitchen_device.id) - entities.async_get_or_create("light", "demo", "5678", suggested_object_id="bedroom") - entities.async_update_entity(state2.entity_id, area_id=area_bedroom.id) + entity_registry.async_get_or_create( + "light", "demo", "5678", suggested_object_id="bedroom" + ) + entity_registry.async_update_entity(state2.entity_id, area_id=area_bedroom.id) # Match on area/domain assert list( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 9f47bb721976..b93562621c31 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -21,8 +21,8 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import ( - device_registry as dev_reg, - entity_registry as ent_reg, + device_registry as dr, + entity_registry as er, service, template, ) @@ -96,10 +96,10 @@ def area_mock(hass): hass.states.async_set("light.Ceiling", STATE_OFF) hass.states.async_set("light.Kitchen", STATE_OFF) - device_in_area = dev_reg.DeviceEntry(area_id="test-area") - device_no_area = dev_reg.DeviceEntry(id="device-no-area-id") - device_diff_area = dev_reg.DeviceEntry(area_id="diff-area") - device_area_a = dev_reg.DeviceEntry(id="device-area-a-id", area_id="area-a") + device_in_area = dr.DeviceEntry(area_id="test-area") + device_no_area = dr.DeviceEntry(id="device-no-area-id") + device_diff_area = dr.DeviceEntry(area_id="diff-area") + device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") mock_device_registry( hass, @@ -111,94 +111,94 @@ def area_mock(hass): }, ) - entity_in_own_area = ent_reg.RegistryEntry( + entity_in_own_area = er.RegistryEntry( entity_id="light.in_own_area", unique_id="in-own-area-id", platform="test", area_id="own-area", ) - config_entity_in_own_area = ent_reg.RegistryEntry( + config_entity_in_own_area = er.RegistryEntry( entity_id="light.config_in_own_area", unique_id="config-in-own-area-id", platform="test", area_id="own-area", entity_category=EntityCategory.CONFIG, ) - hidden_entity_in_own_area = ent_reg.RegistryEntry( + hidden_entity_in_own_area = er.RegistryEntry( entity_id="light.hidden_in_own_area", unique_id="hidden-in-own-area-id", platform="test", area_id="own-area", - hidden_by=ent_reg.RegistryEntryHider.USER, + hidden_by=er.RegistryEntryHider.USER, ) - entity_in_area = ent_reg.RegistryEntry( + entity_in_area = er.RegistryEntry( entity_id="light.in_area", unique_id="in-area-id", platform="test", device_id=device_in_area.id, ) - config_entity_in_area = ent_reg.RegistryEntry( + config_entity_in_area = er.RegistryEntry( entity_id="light.config_in_area", unique_id="config-in-area-id", platform="test", device_id=device_in_area.id, entity_category=EntityCategory.CONFIG, ) - hidden_entity_in_area = ent_reg.RegistryEntry( + hidden_entity_in_area = er.RegistryEntry( entity_id="light.hidden_in_area", unique_id="hidden-in-area-id", platform="test", device_id=device_in_area.id, - hidden_by=ent_reg.RegistryEntryHider.USER, + hidden_by=er.RegistryEntryHider.USER, ) - entity_in_other_area = ent_reg.RegistryEntry( + entity_in_other_area = er.RegistryEntry( entity_id="light.in_other_area", unique_id="in-area-a-id", platform="test", device_id=device_in_area.id, area_id="other-area", ) - entity_assigned_to_area = ent_reg.RegistryEntry( + entity_assigned_to_area = er.RegistryEntry( entity_id="light.assigned_to_area", unique_id="assigned-area-id", platform="test", device_id=device_in_area.id, area_id="test-area", ) - entity_no_area = ent_reg.RegistryEntry( + entity_no_area = er.RegistryEntry( entity_id="light.no_area", unique_id="no-area-id", platform="test", device_id=device_no_area.id, ) - config_entity_no_area = ent_reg.RegistryEntry( + config_entity_no_area = er.RegistryEntry( entity_id="light.config_no_area", unique_id="config-no-area-id", platform="test", device_id=device_no_area.id, entity_category=EntityCategory.CONFIG, ) - hidden_entity_no_area = ent_reg.RegistryEntry( + hidden_entity_no_area = er.RegistryEntry( entity_id="light.hidden_no_area", unique_id="hidden-no-area-id", platform="test", device_id=device_no_area.id, - hidden_by=ent_reg.RegistryEntryHider.USER, + hidden_by=er.RegistryEntryHider.USER, ) - entity_diff_area = ent_reg.RegistryEntry( + entity_diff_area = er.RegistryEntry( entity_id="light.diff_area", unique_id="diff-area-id", platform="test", device_id=device_diff_area.id, ) - entity_in_area_a = ent_reg.RegistryEntry( + entity_in_area_a = er.RegistryEntry( entity_id="light.in_area_a", unique_id="in-area-a-id", platform="test", device_id=device_area_a.id, area_id="area-a", ) - entity_in_area_b = ent_reg.RegistryEntry( + entity_in_area_b = er.RegistryEntry( entity_id="light.in_area_b", unique_id="in-area-b-id", platform="test", @@ -403,11 +403,12 @@ class TestServiceHelpers(unittest.TestCase): assert mock_log.call_count == 3 -async def test_service_call_entry_id(hass: HomeAssistant) -> None: +async def test_service_call_entry_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: """Test service call with entity specified by entity registry ID.""" - registry = ent_reg.async_get(hass) calls = async_mock_service(hass, "test_domain", "test_service") - entry = registry.async_get_or_create( + entry = entity_registry.async_get_or_create( "hello", "hue", "1234", suggested_object_id="world" ) @@ -946,7 +947,7 @@ async def test_domain_control_unauthorized( mock_registry( hass, { - "light.kitchen": ent_reg.RegistryEntry( + "light.kitchen": er.RegistryEntry( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -987,7 +988,7 @@ async def test_domain_control_admin( mock_registry( hass, { - "light.kitchen": ent_reg.RegistryEntry( + "light.kitchen": er.RegistryEntry( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -1025,7 +1026,7 @@ async def test_domain_control_no_user(hass: HomeAssistant) -> None: mock_registry( hass, { - "light.kitchen": ent_reg.RegistryEntry( + "light.kitchen": er.RegistryEntry( entity_id="light.kitchen", unique_id="kitchen", platform="test_domain", @@ -1213,9 +1214,7 @@ async def test_async_extract_entities_warn_referenced( async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: """Test we can find devices that have no entities.""" - device_no_entities = dev_reg.DeviceEntry( - id="device-no-entities", config_entries={"abc"} - ) + device_no_entities = dr.DeviceEntry(id="device-no-entities", config_entries={"abc"}) call = ServiceCall( "homeassistant", From 79bcdf43f7d6e472cac80c2128c00228c5dd7095 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 1 Mar 2023 16:26:20 +0100 Subject: [PATCH 0170/1058] Add `current` sensor for Shelly RPC devices (#88863) --- homeassistant/components/shelly/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 2737270ac764..5e05134fdc32 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -439,6 +439,16 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current": RpcSensorDescription( + key="switch", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "a_current": RpcSensorDescription( key="em", sub_key="a_current", From 137d2f0d735bb65fc8fa407e616418e6b55126c4 Mon Sep 17 00:00:00 2001 From: Emory Penney Date: Wed, 1 Mar 2023 07:33:32 -0800 Subject: [PATCH 0171/1058] Obihai config flow fixes (#88853) * Commit split issue * Clearer name * Add yaml_failure test case * Not sure why this is failing now * Update homeassistant/components/obihai/strings.json Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * PR Feedback * Update homeassistant/components/obihai/config_flow.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 1 + .../components/obihai/config_flow.py | 37 ++++++++++++------- homeassistant/components/obihai/sensor.py | 2 +- homeassistant/components/obihai/strings.json | 2 +- tests/components/obihai/test_config_flow.py | 25 ++++++++++--- 5 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.coveragerc b/.coveragerc index 47376833679d..3c9a1c378a84 100644 --- a/.coveragerc +++ b/.coveragerc @@ -807,6 +807,7 @@ omit = homeassistant/components/nuki/sensor.py homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/oasa_telematics/sensor.py + homeassistant/components/obihai/__init__.py homeassistant/components/obihai/connectivity.py homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index dd2aa0db06d3..2f8dd0075b82 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from .connectivity import validate_auth @@ -27,6 +28,16 @@ DATA_SCHEMA = vol.Schema( ) +async def async_validate_creds(hass: HomeAssistant, user_input: dict[str, Any]) -> bool: + """Manage Obihai options.""" + return await hass.async_add_executor_job( + validate_auth, + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for Obihai.""" @@ -40,12 +51,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - if await self.hass.async_add_executor_job( - validate_auth, - user_input[CONF_HOST], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ): + if await async_validate_creds(self.hass, user_input): return self.async_create_entry( title=user_input[CONF_HOST], data=user_input, @@ -63,11 +69,14 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Handle a flow initialized by importing a config.""" self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - return self.async_create_entry( - title=config.get(CONF_NAME, config[CONF_HOST]), - data={ - CONF_HOST: config[CONF_HOST], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_USERNAME: config[CONF_USERNAME], - }, - ) + if await async_validate_creds(self.hass, config): + return self.async_create_entry( + title=config.get(CONF_NAME, config[CONF_HOST]), + data={ + CONF_HOST: config[CONF_HOST], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_USERNAME: config[CONF_USERNAME], + }, + ) + + return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 4f7b6195e4e3..7524fbc7d47c 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -46,7 +46,7 @@ async def async_setup_platform( "manual_migration", breaks_in_ha_version="2023.6.0", is_fixable=False, - severity=ir.IssueSeverity.ERROR, + severity=ir.IssueSeverity.WARNING, translation_key="manual_migration", ) diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 053343b4501a..fb673675ad7e 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -18,7 +18,7 @@ }, "issues": { "manual_migration": { - "title": "Manual migration required for Obihai", + "title": "Obihai YAML configuration is being removed", "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file." } } diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py index 07d00f15775c..234f1d599677 100644 --- a/tests/components/obihai/test_config_flow.py +++ b/tests/components/obihai/test_config_flow.py @@ -10,6 +10,8 @@ from homeassistant.data_entry_flow import FlowResultType from . import USER_INPUT +VALIDATE_AUTH_PATCH = "homeassistant.components.obihai.config_flow.validate_auth" + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -43,9 +45,7 @@ async def test_auth_failure(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.obihai.config_flow.validate_auth", return_value=False - ): + with patch(VALIDATE_AUTH_PATCH, return_value=False): result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -59,9 +59,7 @@ async def test_auth_failure(hass: HomeAssistant) -> None: async def test_yaml_import(hass: HomeAssistant) -> None: """Test we get the YAML imported.""" - with patch( - "homeassistant.components.obihai.config_flow.validate_auth", return_value=True - ): + with patch(VALIDATE_AUTH_PATCH, return_value=True): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -71,3 +69,18 @@ async def test_yaml_import(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert "errors" not in result + + +async def test_yaml_import_fail(hass: HomeAssistant) -> None: + """Test the YAML import fails.""" + with patch(VALIDATE_AUTH_PATCH, return_value=False): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert "errors" not in result From 12933353b24be8c5bfcaa1f739caef50f8cd5f91 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 16:46:19 +0100 Subject: [PATCH 0172/1058] Drop codeowner from threshold integration (#88973) --- CODEOWNERS | 2 -- homeassistant/components/threshold/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index edee0e9b53bc..fb122a5a0e34 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1213,8 +1213,6 @@ build.json @home-assistant/supervisor /homeassistant/components/thethingsnetwork/ @fabaff /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core -/homeassistant/components/threshold/ @fabaff -/tests/components/threshold/ @fabaff /homeassistant/components/tibber/ @danielhiversen /tests/components/tibber/ @danielhiversen /homeassistant/components/tile/ @bachya diff --git a/homeassistant/components/threshold/manifest.json b/homeassistant/components/threshold/manifest.json index f149bda05d3e..60ef45c845ea 100644 --- a/homeassistant/components/threshold/manifest.json +++ b/homeassistant/components/threshold/manifest.json @@ -1,7 +1,7 @@ { "domain": "threshold", "name": "Threshold", - "codeowners": ["@fabaff"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/threshold", "integration_type": "helper", From 3818e318db3b6613df1cc4a0cf2d3459342be9db Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 16:53:42 +0100 Subject: [PATCH 0173/1058] Improve threshold binary sensor tests (#88972) --- .../threshold/test_binary_sensor.py | 379 +++++++++++++----- 1 file changed, 268 insertions(+), 111 deletions(-) diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index a89c9f4e17a0..f009e4c48a20 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -24,36 +24,50 @@ async def test_sensor_upper(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the threshold + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set( "sensor.test_monitored", 16, {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("entity_id") == "sensor.test_monitored" - assert state.attributes.get("sensor_value") == 16 - assert state.attributes.get("position") == "above" - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == 0.0 - assert state.attributes.get("type") == "upper" - + assert state.attributes["entity_id"] == "sensor.test_monitored" + assert state.attributes["sensor_value"] == 16 + assert state.attributes["position"] == "above" + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == 0.0 + assert state.attributes["type"] == "upper" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 14) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "below" + assert state.state == "off" + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" assert state.state == "off" @@ -70,27 +84,48 @@ async def test_sensor_lower(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the threshold + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set("sensor.test_monitored", 16) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "above" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - assert state.attributes.get("hysteresis") == 0.0 - assert state.attributes.get("type") == "lower" - + assert state.attributes["position"] == "above" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["hysteresis"] == 0.0 + assert state.attributes["type"] == "lower" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 14) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "below" assert state.state == "on" + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "below" + assert state.state == "on" -async def test_sensor_hysteresis(hass: HomeAssistant) -> None: + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 15) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + +async def test_sensor_upper_hysteresis(hass: HomeAssistant) -> None: """Test if source is above threshold using hysteresis.""" config = { "binary_sensor": { @@ -104,46 +139,141 @@ async def test_sensor_hysteresis(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the threshold + hysteresis + hass.states.async_set("sensor.test_monitored", 17.5) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the threshold - hysteresis + hass.states.async_set("sensor.test_monitored", 12.5) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "above" - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == 2.5 - assert state.attributes.get("type") == "upper" - + assert state.attributes["position"] == "above" + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == 2.5 + assert state.attributes["type"] == "upper" + assert state.attributes["position"] == "above" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 13) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "above" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 12) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 17) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - + assert state.attributes["position"] == "above" assert state.state == "on" + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 18) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "on" + + +async def test_sensor_lower_hysteresis(hass: HomeAssistant) -> None: + """Test if source is below threshold using hysteresis.""" + config = { + "binary_sensor": { + "platform": "threshold", + "lower": "15", + "hysteresis": "2.5", + "entity_id": "sensor.test_monitored", + } + } + + assert await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + # Set the monitored sensor's state to the threshold + hysteresis + hass.states.async_set("sensor.test_monitored", 17.5) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the threshold - hysteresis + hass.states.async_set("sensor.test_monitored", 12.5) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + hass.states.async_set("sensor.test_monitored", 20) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["hysteresis"] == 2.5 + assert state.attributes["type"] == "lower" + assert state.attributes["position"] == "above" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 13) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 12) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "below" + assert state.state == "on" + + hass.states.async_set("sensor.test_monitored", 17) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "below" + assert state.state == "on" + + hass.states.async_set("sensor.test_monitored", 18) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 18) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "off" + async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: """Test if source is within the range.""" @@ -159,39 +289,58 @@ async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the lower threshold + hass.states.async_set("sensor.test_monitored", 10) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the upper threshold + hass.states.async_set("sensor.test_monitored", 20) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set( "sensor.test_monitored", 16, {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("entity_id") == "sensor.test_monitored" - assert state.attributes.get("sensor_value") == 16 - assert state.attributes.get("position") == "in_range" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == 0.0 - assert state.attributes.get("type") == "range" - + assert state.attributes["entity_id"] == "sensor.test_monitored" + assert state.attributes["sensor_value"] == 16 + assert state.attributes["position"] == "in_range" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == 0.0 + assert state.attributes["type"] == "range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 9) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "below" + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 21) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" + assert state.state == "off" - assert state.attributes.get("position") == "above" + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 21) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "above" assert state.state == "off" @@ -210,6 +359,34 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() + # Set the monitored sensor's state to the lower threshold - hysteresis + hass.states.async_set("sensor.test_monitored", 8) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the lower threshold + hysteresis + hass.states.async_set("sensor.test_monitored", 12) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the upper threshold + hysteresis + hass.states.async_set("sensor.test_monitored", 22) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + + # Set the monitored sensor's state to the upper threshold - hysteresis + hass.states.async_set("sensor.test_monitored", 18) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "unknown" + hass.states.async_set( "sensor.test_monitored", 16, @@ -219,80 +396,75 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.threshold") - assert state.attributes.get("entity_id") == "sensor.test_monitored" - assert state.attributes.get("sensor_value") == 16 - assert state.attributes.get("position") == "in_range" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == float( + assert state.attributes["entity_id"] == "sensor.test_monitored" + assert state.attributes["sensor_value"] == 16 + assert state.attributes["position"] == "in_range" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == float( config["binary_sensor"]["hysteresis"] ) - assert state.attributes.get("type") == "range" - + assert state.attributes["type"] == "range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 8) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "in_range" + assert state.attributes["position"] == "in_range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 7) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "below" + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 12) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "below" + assert state.attributes["position"] == "below" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 13) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "in_range" + assert state.attributes["position"] == "in_range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 22) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "in_range" + assert state.attributes["position"] == "in_range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", 23) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "above" + assert state.attributes["position"] == "above" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "above" + assert state.attributes["position"] == "above" assert state.state == "off" hass.states.async_set("sensor.test_monitored", 17) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "in_range" + assert state.state == "on" - assert state.attributes.get("position") == "in_range" + hass.states.async_set("sensor.test_monitored", "cat") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "unknown" + assert state.state == "off" + + hass.states.async_set("sensor.test_monitored", 17) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.threshold") + assert state.attributes["position"] == "in_range" assert state.state == "on" @@ -321,30 +493,25 @@ async def test_sensor_in_range_unknown_state( state = hass.states.get("binary_sensor.threshold") - assert state.attributes.get("entity_id") == "sensor.test_monitored" - assert state.attributes.get("sensor_value") == 16 - assert state.attributes.get("position") == "in_range" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - assert state.attributes.get("hysteresis") == 0.0 - assert state.attributes.get("type") == "range" - + assert state.attributes["entity_id"] == "sensor.test_monitored" + assert state.attributes["sensor_value"] == 16 + assert state.attributes["position"] == "in_range" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["hysteresis"] == 0.0 + assert state.attributes["type"] == "range" assert state.state == "on" hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "unknown" + assert state.attributes["position"] == "unknown" assert state.state == "off" hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("position") == "unknown" + assert state.attributes["position"] == "unknown" assert state.state == "off" assert "State is not numerical" not in caplog.text @@ -365,19 +532,14 @@ async def test_sensor_lower_zero_threshold(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 16) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("type") == "lower" - assert state.attributes.get("lower") == float(config["binary_sensor"]["lower"]) - + assert state.attributes["type"] == "lower" + assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) assert state.state == "off" hass.states.async_set("sensor.test_monitored", -3) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.state == "on" @@ -396,17 +558,12 @@ async def test_sensor_upper_zero_threshold(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", -10) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes.get("type") == "upper" - assert state.attributes.get("upper") == float(config["binary_sensor"]["upper"]) - + assert state.attributes["type"] == "upper" + assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) assert state.state == "off" hass.states.async_set("sensor.test_monitored", 2) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.state == "on" From ee78864b0568330a8118dcf54e9d461b55134516 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 16:54:00 +0100 Subject: [PATCH 0174/1058] Adjust entity registry access in homekit tests (#88959) --- tests/components/homekit/conftest.py | 14 +- tests/components/homekit/test_aidmanager.py | 55 +++--- tests/components/homekit/test_config_flow.py | 26 +-- tests/components/homekit/test_diagnostics.py | 7 +- tests/components/homekit/test_homekit.py | 162 ++++++++++-------- tests/components/homekit/test_init.py | 5 +- .../components/homekit/test_type_triggers.py | 10 +- 7 files changed, 149 insertions(+), 130 deletions(-) diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py index 92cd46b51e6b..fe151c902cb2 100644 --- a/tests/components/homekit/conftest.py +++ b/tests/components/homekit/conftest.py @@ -10,7 +10,7 @@ from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import BRIDGE_NAME, EVENT_HOMEKIT_CHANGED from homeassistant.components.homekit.iidmanager import AccessoryIIDStorage -from tests.common import async_capture_events, mock_device_registry, mock_registry +from tests.common import async_capture_events @pytest.fixture @@ -103,18 +103,6 @@ def events(hass): return async_capture_events(hass, EVENT_HOMEKIT_CHANGED) -@pytest.fixture(name="device_reg") -def device_reg_fixture(hass): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) - - -@pytest.fixture(name="entity_reg") -def entity_reg_fixture(hass): - """Return an empty, loaded, registry.""" - return mock_registry(hass) - - @pytest.fixture def demo_cleanup(hass): """Clean up device tracker demo file.""" diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 1dafe7a11ad0..64a44cd38a98 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -3,7 +3,6 @@ import os from unittest.mock import patch from fnvhash import fnv1a_32 -import pytest from homeassistant.components.homekit.aidmanager import ( AccessoryAidStorage, @@ -11,39 +10,31 @@ from homeassistant.components.homekit.aidmanager import ( get_system_unique_id, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.storage import STORAGE_DIR -from tests.common import MockConfigEntry, mock_device_registry, mock_registry +from tests.common import MockConfigEntry -@pytest.fixture -def device_reg(hass): - """Return an empty, loaded, registry.""" - return mock_device_registry(hass) - - -@pytest.fixture -def entity_reg(hass): - """Return an empty, loaded, registry.""" - return mock_registry(hass) - - -async def test_aid_generation(hass: HomeAssistant, device_reg, entity_reg) -> None: +async def test_aid_generation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test generating aids.""" config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - light_ent = entity_reg.async_get_or_create( + light_ent = entity_registry.async_get_or_create( "light", "device", "unique_id", device_id=device_entry.id ) - light_ent2 = entity_reg.async_get_or_create( + light_ent2 = entity_registry.async_get_or_create( "light", "device", "other_unique_id", device_id=device_entry.id ) - remote_ent = entity_reg.async_get_or_create( + remote_ent = entity_registry.async_get_or_create( "remote", "device", "unique_id", device_id=device_entry.id ) hass.states.async_set(light_ent.entity_id, "on") @@ -99,13 +90,17 @@ async def test_aid_generation(hass: HomeAssistant, device_reg, entity_reg) -> No ) -async def test_no_aid_collision(hass: HomeAssistant, device_reg, entity_reg) -> None: +async def test_no_aid_collision( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test generating aids.""" config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) with patch( @@ -117,7 +112,7 @@ async def test_no_aid_collision(hass: HomeAssistant, device_reg, entity_reg) -> seen_aids = set() for unique_id in range(0, 202): - ent = entity_reg.async_get_or_create( + ent = entity_registry.async_get_or_create( "light", "device", unique_id, device_id=device_entry.id ) hass.states.async_set(ent.entity_id, "on") @@ -127,7 +122,9 @@ async def test_no_aid_collision(hass: HomeAssistant, device_reg, entity_reg) -> async def test_aid_generation_no_unique_ids_handles_collision( - hass: HomeAssistant, device_reg, entity_reg + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test colliding aids is stable.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -135,9 +132,9 @@ async def test_aid_generation_no_unique_ids_handles_collision( aid_storage = AccessoryAidStorage(hass, config_entry) await aid_storage.async_initialize() - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) seen_aids = set() @@ -154,7 +151,7 @@ async def test_aid_generation_no_unique_ids_handles_collision( assert aid not in seen_aids seen_aids.add(aid) - light_ent = entity_reg.async_get_or_create( + light_ent = entity_registry.async_get_or_create( "light", "device", "unique_id", device_id=device_entry.id ) hass.states.async_set(light_ent.entity_id, "on") diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index 2beece05b6eb..3de10491f391 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -13,7 +13,7 @@ from homeassistant.components.homekit.const import ( from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_IMPORT from homeassistant.const import CONF_NAME, CONF_PORT, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_registry import RegistryEntry, RegistryEntryHider +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import CONF_INCLUDE_DOMAINS from homeassistant.setup import async_setup_component @@ -398,8 +398,8 @@ async def test_options_flow_devices( port_mock, hass: HomeAssistant, demo_cleanup, - device_reg, - entity_reg, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_get_source_ip, mock_async_zeroconf: None, ) -> None: @@ -451,7 +451,7 @@ async def test_options_flow_devices( assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "exclude" - entry = entity_reg.async_get("light.ceiling_lights") + entry = entity_registry.async_get("light.ceiling_lights") assert entry is not None device_id = entry.device_id @@ -1379,7 +1379,7 @@ async def test_options_flow_exclude_mode_skips_category_entities( mock_get_source_ip, hk_driver, mock_async_zeroconf: None, - entity_reg, + entity_registry: er.EntityRegistry, ) -> None: """Ensure exclude mode does not offer category entities.""" config_entry = _mock_config_entry_with_options_populated() @@ -1389,7 +1389,7 @@ async def test_options_flow_exclude_mode_skips_category_entities( hass.states.async_set("media_player.sonos", "off") hass.states.async_set("switch.other", "off") - sonos_config_switch: RegistryEntry = entity_reg.async_get_or_create( + sonos_config_switch = entity_registry.async_get_or_create( "switch", "sonos", "config", @@ -1398,7 +1398,7 @@ async def test_options_flow_exclude_mode_skips_category_entities( ) hass.states.async_set(sonos_config_switch.entity_id, "off") - sonos_notconfig_switch: RegistryEntry = entity_reg.async_get_or_create( + sonos_notconfig_switch = entity_registry.async_get_or_create( "switch", "sonos", "notconfig", @@ -1484,7 +1484,7 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( mock_get_source_ip, hk_driver, mock_async_zeroconf: None, - entity_reg, + entity_registry: er.EntityRegistry, ) -> None: """Ensure exclude mode does not offer hidden entities.""" config_entry = _mock_config_entry_with_options_populated() @@ -1494,12 +1494,12 @@ async def test_options_flow_exclude_mode_skips_hidden_entities( hass.states.async_set("media_player.sonos", "off") hass.states.async_set("switch.other", "off") - sonos_hidden_switch: RegistryEntry = entity_reg.async_get_or_create( + sonos_hidden_switch = entity_registry.async_get_or_create( "switch", "sonos", "config", device_id="1234", - hidden_by=RegistryEntryHider.INTEGRATION, + hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(sonos_hidden_switch.entity_id, "off") await hass.async_block_till_done() @@ -1569,7 +1569,7 @@ async def test_options_flow_include_mode_allows_hidden_entities( mock_get_source_ip, hk_driver, mock_async_zeroconf: None, - entity_reg, + entity_registry: er.EntityRegistry, ) -> None: """Ensure include mode does not offer hidden entities.""" config_entry = _mock_config_entry_with_options_populated() @@ -1579,12 +1579,12 @@ async def test_options_flow_include_mode_allows_hidden_entities( hass.states.async_set("media_player.sonos", "off") hass.states.async_set("switch.other", "off") - sonos_hidden_switch: RegistryEntry = entity_reg.async_get_or_create( + sonos_hidden_switch = entity_registry.async_get_or_create( "switch", "sonos", "config", device_id="1234", - hidden_by=RegistryEntryHider.INTEGRATION, + hidden_by=er.RegistryEntryHider.INTEGRATION, ) hass.states.async_set(sonos_hidden_switch.entity_id, "off") await hass.async_block_till_done() diff --git a/tests/components/homekit/test_diagnostics.py b/tests/components/homekit/test_diagnostics.py index 8d4dfe36c046..58babc0ccb09 100644 --- a/tests/components/homekit/test_diagnostics.py +++ b/tests/components/homekit/test_diagnostics.py @@ -9,6 +9,7 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.const import CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STARTED 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 .util import async_init_integration @@ -314,8 +315,8 @@ async def test_config_entry_with_trigger_accessory( mock_async_zeroconf: None, events, demo_cleanup, - device_reg, - entity_reg, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test generating diagnostics for a bridge config entry with a trigger accessory.""" assert await async_setup_component(hass, "demo", {"demo": {}}) @@ -326,7 +327,7 @@ async def test_config_entry_with_trigger_accessory( assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() - entry = entity_reg.async_get("light.ceiling_lights") + entry = entity_registry.async_get("light.ceiling_lights") assert entry is not None device_id = entry.device_id diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 5b6a6d50184e..a6dbdbebdd44 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -46,9 +46,14 @@ from homeassistant.const import ( PERCENTAGE, SERVICE_RELOAD, STATE_ON, + EntityCategory, ) from homeassistant.core import HomeAssistant, HomeAssistantError, State -from homeassistant.helpers import device_registry, entity_registry as er, instance_id +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + instance_id, +) from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, CONF_EXCLUDE_ENTITIES, @@ -503,15 +508,12 @@ async def test_homekit_entity_glob_filter( async def test_homekit_entity_glob_filter_with_config_entities( - hass: HomeAssistant, mock_async_zeroconf: None, entity_reg + hass: HomeAssistant, mock_async_zeroconf: None, entity_registry: er.EntityRegistry ) -> None: """Test the entity filter with configuration entities.""" entry = await async_init_integration(hass) - from homeassistant.const import EntityCategory - from homeassistant.helpers.entity_registry import RegistryEntry - - select_config_entity: RegistryEntry = entity_reg.async_get_or_create( + select_config_entity = entity_registry.async_get_or_create( "select", "any", "any", @@ -520,7 +522,7 @@ async def test_homekit_entity_glob_filter_with_config_entities( ) hass.states.async_set(select_config_entity.entity_id, "off") - switch_config_entity: RegistryEntry = entity_reg.async_get_or_create( + switch_config_entity = entity_registry.async_get_or_create( "switch", "any", "any", @@ -559,14 +561,12 @@ async def test_homekit_entity_glob_filter_with_config_entities( async def test_homekit_entity_glob_filter_with_hidden_entities( - hass: HomeAssistant, mock_async_zeroconf: None, entity_reg + hass: HomeAssistant, mock_async_zeroconf: None, entity_registry: er.EntityRegistry ) -> None: """Test the entity filter with hidden entities.""" entry = await async_init_integration(hass) - from homeassistant.helpers.entity_registry import RegistryEntry - - select_config_entity: RegistryEntry = entity_reg.async_get_or_create( + select_config_entity = entity_registry.async_get_or_create( "select", "any", "any", @@ -575,7 +575,7 @@ async def test_homekit_entity_glob_filter_with_hidden_entities( ) hass.states.async_set(select_config_entity.entity_id, "off") - switch_config_entity: RegistryEntry = entity_reg.async_get_or_create( + switch_config_entity = entity_registry.async_get_or_create( "switch", "any", "any", @@ -614,7 +614,10 @@ async def test_homekit_entity_glob_filter_with_hidden_entities( async def test_homekit_start( - hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, device_reg + hass: HomeAssistant, + hk_driver, + mock_async_zeroconf: None, + device_registry: dr.DeviceRegistry, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -627,8 +630,8 @@ async def test_homekit_start( acc = Accessory(hk_driver, "any") homekit.driver.accessory = acc - connection = (device_registry.CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF") - bridge_with_wrong_mac = device_reg.async_get_or_create( + connection = (dr.CONNECTION_NETWORK_MAC, "AA:BB:CC:DD:EE:FF") + bridge_with_wrong_mac = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={connection}, manufacturer="Any", @@ -661,14 +664,14 @@ async def test_homekit_start( await hass.async_block_till_done() assert not hk_driver_start.called - assert device_reg.async_get(bridge_with_wrong_mac.id) is None + assert device_registry.async_get(bridge_with_wrong_mac.id) is None - device = device_reg.async_get_device( + device = device_registry.async_get_device( {(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} ) assert device - formatted_mac = device_registry.format_mac(homekit.driver.state.mac) - assert (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + formatted_mac = dr.format_mac(homekit.driver.state.mac) + assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections # Start again to make sure the registry entry is kept homekit.status = STATUS_READY @@ -679,14 +682,14 @@ async def test_homekit_start( ) as hk_driver_start: await homekit.async_start() - device = device_reg.async_get_device( + device = device_registry.async_get_device( {(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} ) assert device - formatted_mac = device_registry.format_mac(homekit.driver.state.mac) - assert (device_registry.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections + formatted_mac = dr.format_mac(homekit.driver.state.mac) + assert (dr.CONNECTION_NETWORK_MAC, formatted_mac) in device.connections - assert len(device_reg.devices) == 1 + assert len(device_registry.devices) == 1 assert homekit.driver.state.config_version == 1 @@ -736,8 +739,8 @@ async def test_homekit_start_with_a_device( hk_driver, mock_async_zeroconf: None, demo_cleanup, - device_reg, - entity_reg, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test HomeKit start method with a device.""" @@ -747,7 +750,7 @@ async def test_homekit_start_with_a_device( assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() - reg_entry = entity_reg.async_get("light.ceiling_lights") + reg_entry = entity_registry.async_get("light.ceiling_lights") assert reg_entry is not None device_id = reg_entry.device_id await async_init_entry(hass, entry) @@ -841,7 +844,7 @@ async def test_homekit_reset_accessories( async def test_homekit_unpair( - hass: HomeAssistant, device_reg, mock_async_zeroconf: None + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None ) -> None: """Test unpairing HomeKit accessories.""" @@ -873,9 +876,9 @@ async def test_homekit_unpair( state.add_paired_client("client4", "any", b"0") state.add_paired_client("client5", "any", b"0") - formatted_mac = device_registry.format_mac(state.mac) - hk_bridge_dev = device_reg.async_get_device( - {}, {(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)} + formatted_mac = dr.format_mac(state.mac) + hk_bridge_dev = device_registry.async_get_device( + {}, {(dr.CONNECTION_NETWORK_MAC, formatted_mac)} ) await hass.services.async_call( @@ -890,7 +893,7 @@ async def test_homekit_unpair( async def test_homekit_unpair_missing_device_id( - hass: HomeAssistant, device_reg, mock_async_zeroconf: None + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None ) -> None: """Test unpairing HomeKit accessories with invalid device id.""" @@ -930,7 +933,7 @@ async def test_homekit_unpair_missing_device_id( async def test_homekit_unpair_not_homekit_device( - hass: HomeAssistant, device_reg, mock_async_zeroconf: None + hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None ) -> None: """Test unpairing HomeKit accessories with a non-homekit device id.""" @@ -957,12 +960,12 @@ async def test_homekit_unpair_not_homekit_device( homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=not_homekit_entry.entry_id, sw_version="0.16.0", model="Powerwall 2", manufacturer="Tesla", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) state = homekit.driver.state @@ -1299,7 +1302,11 @@ async def test_homekit_too_many_accessories( async def test_homekit_finds_linked_batteries( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1311,30 +1318,30 @@ async def test_homekit_finds_linked_batteries( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.0", hw_version="2.34", model="Powerwall 2", manufacturer="Tesla", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - binary_charging_sensor = entity_reg.async_get_or_create( + binary_charging_sensor = entity_registry.async_get_or_create( "binary_sensor", "powerwall", "battery_charging", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) - battery_sensor = entity_reg.async_get_or_create( + battery_sensor = entity_registry.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, original_device_class=SensorDeviceClass.BATTERY, ) - light = entity_reg.async_get_or_create( + light = entity_registry.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id ) @@ -1372,7 +1379,11 @@ async def test_homekit_finds_linked_batteries( async def test_homekit_async_get_integration_fails( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test that we continue if async_get_integration fails.""" entry = await async_init_integration(hass) @@ -1383,28 +1394,28 @@ async def test_homekit_async_get_integration_fails( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.0", model="Powerwall 2", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - binary_charging_sensor = entity_reg.async_get_or_create( + binary_charging_sensor = entity_registry.async_get_or_create( "binary_sensor", "invalid_integration_does_not_exist", "battery_charging", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) - battery_sensor = entity_reg.async_get_or_create( + battery_sensor = entity_registry.async_get_or_create( "sensor", "invalid_integration_does_not_exist", "battery", device_id=device_entry.id, original_device_class=SensorDeviceClass.BATTERY, ) - light = entity_reg.async_get_or_create( + light = entity_registry.async_get_or_create( "light", "invalid_integration_does_not_exist", "demo", device_id=device_entry.id ) @@ -1594,7 +1605,11 @@ async def test_homekit_uses_system_zeroconf( async def test_homekit_ignored_missing_devices( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test HomeKit handles a device in the entity registry but missing from the device registry.""" @@ -1606,40 +1621,40 @@ async def test_homekit_ignored_missing_devices( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.0", model="Powerwall 2", manufacturer="Tesla", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_reg.async_get_or_create( + entity_registry.async_get_or_create( "binary_sensor", "powerwall", "battery_charging", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ) - entity_reg.async_get_or_create( + entity_registry.async_get_or_create( "sensor", "powerwall", "battery", device_id=device_entry.id, original_device_class=SensorDeviceClass.BATTERY, ) - light = entity_reg.async_get_or_create( + light = entity_registry.async_get_or_create( "light", "powerwall", "demo", device_id=device_entry.id ) - before_removal = entity_reg.entities.copy() + before_removal = entity_registry.entities.copy() # Delete the device to make sure we fallback # to using the platform - device_reg.async_remove_device(device_entry.id) + device_registry.async_remove_device(device_entry.id) # Wait for the entities to be removed await asyncio.sleep(0) await asyncio.sleep(0) # Restore the registry - entity_reg.entities = before_removal + entity_registry.entities = before_removal hass.states.async_set(light.entity_id, STATE_ON) hass.states.async_set("light.two", STATE_ON) @@ -1664,7 +1679,11 @@ async def test_homekit_ignored_missing_devices( async def test_homekit_finds_linked_motion_sensors( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1676,22 +1695,22 @@ async def test_homekit_finds_linked_motion_sensors( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.0", model="Camera Server", manufacturer="Ubq", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - binary_motion_sensor = entity_reg.async_get_or_create( + binary_motion_sensor = entity_registry.async_get_or_create( "binary_sensor", "camera", "motion_sensor", device_id=device_entry.id, original_device_class=BinarySensorDeviceClass.MOTION, ) - camera = entity_reg.async_get_or_create( + camera = entity_registry.async_get_or_create( "camera", "camera", "demo", device_id=device_entry.id ) @@ -1726,7 +1745,11 @@ async def test_homekit_finds_linked_motion_sensors( async def test_homekit_finds_linked_humidity_sensors( - hass: HomeAssistant, hk_driver, device_reg, entity_reg, mock_async_zeroconf: None + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_async_zeroconf: None, ) -> None: """Test HomeKit start method.""" entry = await async_init_integration(hass) @@ -1738,22 +1761,22 @@ async def test_homekit_finds_linked_humidity_sensors( config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) - device_entry = device_reg.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, sw_version="0.16.1", model="Smart Brainy Clever Humidifier", manufacturer="Home Assistant", - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - humidity_sensor = entity_reg.async_get_or_create( + humidity_sensor = entity_registry.async_get_or_create( "sensor", "humidifier", "humidity_sensor", device_id=device_entry.id, original_device_class=SensorDeviceClass.HUMIDITY, ) - humidifier = entity_reg.async_get_or_create( + humidifier = entity_registry.async_get_or_create( "humidifier", "humidifier", "demo", device_id=device_entry.id ) @@ -1862,7 +1885,10 @@ async def test_reload(hass: HomeAssistant, mock_async_zeroconf: None) -> None: async def test_homekit_start_in_accessory_mode( - hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, device_reg + hass: HomeAssistant, + hk_driver, + mock_async_zeroconf: None, + device_registry: dr.DeviceRegistry, ) -> None: """Test HomeKit start method in accessory mode.""" entry = await async_init_integration(hass) @@ -1896,7 +1922,7 @@ async def test_homekit_start_in_accessory_mode_unsupported_entity( hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, - device_reg, + device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test HomeKit start method in accessory mode with an unsupported entity.""" @@ -1930,7 +1956,7 @@ async def test_homekit_start_in_accessory_mode_missing_entity( hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, - device_reg, + device_registry: dr.DeviceRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test HomeKit start method in accessory mode when entity is not available.""" diff --git a/tests/components/homekit/test_init.py b/tests/components/homekit/test_init.py index 5445d3c8ae1f..2bb9a4972a3e 100644 --- a/tests/components/homekit/test_init.py +++ b/tests/components/homekit/test_init.py @@ -16,6 +16,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .util import PATH_HOMEKIT @@ -71,7 +72,7 @@ async def test_bridge_with_triggers( hass: HomeAssistant, hk_driver, mock_async_zeroconf: None, - entity_reg, + entity_registry: er.EntityRegistry, caplog: pytest.LogCaptureFixture, ) -> None: """Test we can setup a bridge with triggers and we ignore numeric states. @@ -83,7 +84,7 @@ async def test_bridge_with_triggers( assert await async_setup_component(hass, "demo", {"demo": {}}) await hass.async_block_till_done() - entry = entity_reg.async_get("cover.living_room_window") + entry = entity_registry.async_get("cover.living_room_window") assert entry is not None device_id = entry.device_id diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index e46bcaf82d2d..fd77499ff091 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -6,13 +6,19 @@ from homeassistant.components.homekit.const import CHAR_PROGRAMMABLE_SWITCH_EVEN from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.const import STATE_OFF, STATE_ON 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 tests.common import MockConfigEntry, async_get_device_automations async def test_programmable_switch_button_fires_on_trigger( - hass: HomeAssistant, hk_driver, events, demo_cleanup, device_reg, entity_reg + hass: HomeAssistant, + hk_driver, + events, + demo_cleanup, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test that DeviceTriggerAccessory fires the programmable switch event on trigger.""" hk_driver.publish = MagicMock() @@ -24,7 +30,7 @@ async def test_programmable_switch_button_fires_on_trigger( hass.states.async_set("light.ceiling_lights", STATE_OFF) await hass.async_block_till_done() - entry = entity_reg.async_get("light.ceiling_lights") + entry = entity_registry.async_get("light.ceiling_lights") assert entry is not None device_id = entry.device_id From b607a09e4b28e0351c4b740540ee77a0f85d4b70 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Mar 2023 17:10:19 +0100 Subject: [PATCH 0175/1058] Add Home Assistant with space as brand (#88976) --- homeassistant/components/thread/discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index d78c546cce74..5a2ee54c5bb0 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -18,6 +18,7 @@ KNOWN_BRANDS: dict[str | None, str] = { "Apple Inc.": "apple", "Google Inc.": "google", "HomeAssistant": "homeassistant", + "Home Assistant": "homeassistant", } THREAD_TYPE = "_meshcop._udp.local." CLASS_IN = 1 From 89c276bb6b66cd13fc989b32e32a25ad7daabd80 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Mar 2023 17:12:37 +0100 Subject: [PATCH 0176/1058] Update frontend to 20230301.0 (#88975) --- 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 3d6fedb07068..9cd10bb4d0a3 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==20230227.0"] + "requirements": ["home-assistant-frontend==20230301.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b8f1d3f5a73f..1c35e9910f12 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230227.0 +home-assistant-frontend==20230301.0 home-assistant-intents==2023.2.28 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 13818635d9c2..927ee4d3955a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230227.0 +home-assistant-frontend==20230301.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e2eab174396..d9026fc51e11 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230227.0 +home-assistant-frontend==20230301.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From 3f32c5d2addafc53c6621f5c7bfa793057b1667c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Mar 2023 12:29:57 -0500 Subject: [PATCH 0177/1058] Yaml use dict (#88977) * Use built-in dict instead of OrderedDict * Use dict instead of OrderedDict in YAML --- homeassistant/util/yaml/dumper.py | 7 +- homeassistant/util/yaml/loader.py | 21 +- homeassistant/util/yaml/objects.py | 4 + .../blueprint/snapshots/test_importer.ambr | 338 +++++++++--------- 4 files changed, 190 insertions(+), 180 deletions(-) diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index db8b496d90ec..a3fba653042f 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -4,7 +4,7 @@ from typing import Any import yaml -from .objects import Input, NodeListClass +from .objects import Input, NodeDictClass, NodeListClass # mypy: allow-untyped-calls, no-warn-return-any @@ -74,6 +74,11 @@ add_representer( lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), ) +add_representer( + NodeDictClass, + lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), +) + add_representer( NodeListClass, lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index bf8a4e9541a7..b5840a79e8d8 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -1,7 +1,6 @@ """Custom loader.""" from __future__ import annotations -from collections import OrderedDict from collections.abc import Iterator import fnmatch from io import StringIO, TextIOWrapper @@ -25,7 +24,7 @@ except ImportError: from homeassistant.exceptions import HomeAssistantError from .const import SECRET_YAML -from .objects import Input, NodeListClass, NodeStrClass +from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass # mypy: allow-untyped-calls, no-warn-return-any @@ -205,7 +204,7 @@ def _parse_yaml( # We convert that to an empty dict return ( yaml.load(content, Loader=lambda stream: loader(stream, secrets)) - or OrderedDict() + or NodeDictClass() ) @@ -276,9 +275,9 @@ def _find_files(directory: str, pattern: str) -> Iterator[str]: yield filename -def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> OrderedDict: +def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDictClass: """Load multiple files from directory as a dictionary.""" - mapping: OrderedDict = OrderedDict() + mapping = NodeDictClass() loc = os.path.join(os.path.dirname(loader.get_name()), node.value) for fname in _find_files(loc, "*.yaml"): filename = os.path.splitext(os.path.basename(fname))[0] @@ -290,9 +289,9 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> Ordere def _include_dir_merge_named_yaml( loader: LoaderType, node: yaml.nodes.Node -) -> OrderedDict: +) -> NodeDictClass: """Load multiple files from directory as a merged dictionary.""" - mapping: OrderedDict = OrderedDict() + mapping = NodeDictClass() loc = os.path.join(os.path.dirname(loader.get_name()), node.value) for fname in _find_files(loc, "*.yaml"): if os.path.basename(fname) == SECRET_YAML: @@ -330,7 +329,9 @@ def _include_dir_merge_list_yaml( return _add_reference(merged_list, loader, node) -def _ordered_dict(loader: LoaderType, node: yaml.nodes.MappingNode) -> OrderedDict: +def _handle_mapping_tag( + loader: LoaderType, node: yaml.nodes.MappingNode +) -> NodeDictClass: """Load YAML mappings into an ordered dictionary to preserve key order.""" loader.flatten_mapping(node) nodes = loader.construct_pairs(node) @@ -361,7 +362,7 @@ def _ordered_dict(loader: LoaderType, node: yaml.nodes.MappingNode) -> OrderedDi ) seen[key] = line - return _add_reference(OrderedDict(nodes), loader, node) + return _add_reference(NodeDictClass(nodes), loader, node) def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: @@ -398,7 +399,7 @@ def add_constructor(tag: Any, constructor: Any) -> None: add_constructor("!include", _include_yaml) -add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict) +add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _handle_mapping_tag) add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq) add_constructor("!env_var", _env_var_yaml) add_constructor("!secret", secret_yaml) diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py index 2d318a9def0e..e7b262ad4965 100644 --- a/homeassistant/util/yaml/objects.py +++ b/homeassistant/util/yaml/objects.py @@ -14,6 +14,10 @@ class NodeStrClass(str): """Wrapper class to be able to add attributes on a string.""" +class NodeDictClass(dict): + """Wrapper class to be able to add attributes on a dict.""" + + @dataclass(frozen=True) class Input: """Input that should be substituted.""" diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 6e5648b54d90..002d5204dc80 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -1,25 +1,79 @@ # serializer version: 1 # name: test_extract_blueprint_from_community_topic - OrderedDict({ - 'remote': OrderedDict({ - 'name': 'Remote', - 'description': 'IKEA remote to use', + NodeDictClass({ + 'brightness': NodeDictClass({ + 'default': 50, + 'description': 'Brightness of the light(s) when turning on', + 'name': 'Brightness', 'selector': dict({ - 'device': OrderedDict({ - 'integration': 'zha', - 'manufacturer': 'IKEA of Sweden', - 'model': 'TRADFRI remote control', - 'multiple': False, + 'number': NodeDictClass({ + 'max': 100.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '%', }), }), }), - 'light': OrderedDict({ - 'name': 'Light(s)', - 'description': 'The light(s) to control', + 'button_left_long': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on long left button press', + 'name': 'Left button - long press', 'selector': dict({ - 'target': OrderedDict({ + 'action': dict({ + }), + }), + }), + 'button_left_short': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on short left button press', + 'name': 'Left button - short press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_long': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on long right button press', + 'name': 'Right button - long press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_short': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on short right button press', + 'name': 'Right button - short press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'force_brightness': NodeDictClass({ + 'default': False, + 'description': ''' + Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. + + ''', + 'name': 'Force turn on brightness', + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'light': NodeDictClass({ + 'description': 'The light(s) to control', + 'name': 'Light(s)', + 'selector': dict({ + 'target': NodeDictClass({ 'entity': list([ - OrderedDict({ + NodeDictClass({ 'domain': list([ 'light', ]), @@ -28,95 +82,95 @@ }), }), }), - 'force_brightness': OrderedDict({ - 'name': 'Force turn on brightness', - 'description': ''' - Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. - - ''', - 'default': False, + 'remote': NodeDictClass({ + 'description': 'IKEA remote to use', + 'name': 'Remote', 'selector': dict({ - 'boolean': dict({ - }), - }), - }), - 'brightness': OrderedDict({ - 'name': 'Brightness', - 'description': 'Brightness of the light(s) when turning on', - 'default': 50, - 'selector': dict({ - 'number': OrderedDict({ - 'min': 0.0, - 'max': 100.0, - 'mode': 'slider', - 'step': 1.0, - 'unit_of_measurement': '%', - }), - }), - }), - 'button_left_short': OrderedDict({ - 'name': 'Left button - short press', - 'description': 'Action to run on short left button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_left_long': OrderedDict({ - 'name': 'Left button - long press', - 'description': 'Action to run on long left button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_right_short': OrderedDict({ - 'name': 'Right button - short press', - 'description': 'Action to run on short right button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_right_long': OrderedDict({ - 'name': 'Right button - long press', - 'description': 'Action to run on long right button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ + 'device': NodeDictClass({ + 'integration': 'zha', + 'manufacturer': 'IKEA of Sweden', + 'model': 'TRADFRI remote control', + 'multiple': False, }), }), }), }) # --- # name: test_fetch_blueprint_from_community_url - OrderedDict({ - 'remote': OrderedDict({ - 'name': 'Remote', - 'description': 'IKEA remote to use', + NodeDictClass({ + 'brightness': NodeDictClass({ + 'default': 50, + 'description': 'Brightness of the light(s) when turning on', + 'name': 'Brightness', 'selector': dict({ - 'device': OrderedDict({ - 'integration': 'zha', - 'manufacturer': 'IKEA of Sweden', - 'model': 'TRADFRI remote control', - 'multiple': False, + 'number': NodeDictClass({ + 'max': 100.0, + 'min': 0.0, + 'mode': 'slider', + 'step': 1.0, + 'unit_of_measurement': '%', }), }), }), - 'light': OrderedDict({ - 'name': 'Light(s)', - 'description': 'The light(s) to control', + 'button_left_long': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on long left button press', + 'name': 'Left button - long press', 'selector': dict({ - 'target': OrderedDict({ + 'action': dict({ + }), + }), + }), + 'button_left_short': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on short left button press', + 'name': 'Left button - short press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_long': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on long right button press', + 'name': 'Right button - long press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'button_right_short': NodeDictClass({ + 'default': NodeListClass([ + ]), + 'description': 'Action to run on short right button press', + 'name': 'Right button - short press', + 'selector': dict({ + 'action': dict({ + }), + }), + }), + 'force_brightness': NodeDictClass({ + 'default': False, + 'description': ''' + Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. + + ''', + 'name': 'Force turn on brightness', + 'selector': dict({ + 'boolean': dict({ + }), + }), + }), + 'light': NodeDictClass({ + 'description': 'The light(s) to control', + 'name': 'Light(s)', + 'selector': dict({ + 'target': NodeDictClass({ 'entity': list([ - OrderedDict({ + NodeDictClass({ 'domain': list([ 'light', ]), @@ -125,94 +179,26 @@ }), }), }), - 'force_brightness': OrderedDict({ - 'name': 'Force turn on brightness', - 'description': ''' - Force the brightness to the set level below, when the "on" button on the remote is pushed and lights turn on. - - ''', - 'default': False, + 'remote': NodeDictClass({ + 'description': 'IKEA remote to use', + 'name': 'Remote', 'selector': dict({ - 'boolean': dict({ - }), - }), - }), - 'brightness': OrderedDict({ - 'name': 'Brightness', - 'description': 'Brightness of the light(s) when turning on', - 'default': 50, - 'selector': dict({ - 'number': OrderedDict({ - 'min': 0.0, - 'max': 100.0, - 'mode': 'slider', - 'step': 1.0, - 'unit_of_measurement': '%', - }), - }), - }), - 'button_left_short': OrderedDict({ - 'name': 'Left button - short press', - 'description': 'Action to run on short left button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_left_long': OrderedDict({ - 'name': 'Left button - long press', - 'description': 'Action to run on long left button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_right_short': OrderedDict({ - 'name': 'Right button - short press', - 'description': 'Action to run on short right button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ - }), - }), - }), - 'button_right_long': OrderedDict({ - 'name': 'Right button - long press', - 'description': 'Action to run on long right button press', - 'default': NodeListClass([ - ]), - 'selector': dict({ - 'action': dict({ + 'device': NodeDictClass({ + 'integration': 'zha', + 'manufacturer': 'IKEA of Sweden', + 'model': 'TRADFRI remote control', + 'multiple': False, }), }), }), }) # --- # name: test_fetch_blueprint_from_github_gist_url - OrderedDict({ - 'motion_entity': OrderedDict({ - 'name': 'Motion Sensor', - 'selector': dict({ - 'entity': OrderedDict({ - 'domain': list([ - 'binary_sensor', - ]), - 'device_class': list([ - 'motion', - ]), - 'multiple': False, - }), - }), - }), - 'light_entity': OrderedDict({ + NodeDictClass({ + 'light_entity': NodeDictClass({ 'name': 'Light', 'selector': dict({ - 'entity': OrderedDict({ + 'entity': NodeDictClass({ 'domain': list([ 'light', ]), @@ -220,5 +206,19 @@ }), }), }), + 'motion_entity': NodeDictClass({ + 'name': 'Motion Sensor', + 'selector': dict({ + 'entity': NodeDictClass({ + 'device_class': list([ + 'motion', + ]), + 'domain': list([ + 'binary_sensor', + ]), + 'multiple': False, + }), + }), + }), }) # --- From bdbec491eb49652d774b0fef67d5eb89677111cc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Mar 2023 18:40:26 +0100 Subject: [PATCH 0178/1058] Enable RUFF ICN001 for registries (#88875) * Add issue_registry to RUFF extend aliases * Add area_registry to RUFF extend aliases * Add device_registry to RUFF extend aliases * Add entity_registry to RUFF extend aliases * Adjust scaffold --- pyproject.toml | 4 ++++ .../device_action/integration/device_action.py | 7 +++---- .../integration/device_condition.py | 13 ++++++++----- .../device_trigger/integration/device_trigger.py | 6 +++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2e0c0d9ebc38..c6e8d6db0e96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -278,7 +278,11 @@ ignore = [ [tool.ruff.flake8-import-conventions.extend-aliases] voluptuous = "vol" +"homeassistant.helpers.area_registry" = "ar" "homeassistant.helpers.config_validation" = "cv" +"homeassistant.helpers.device_registry" = "dr" +"homeassistant.helpers.entity_registry" = "er" +"homeassistant.helpers.issue_registry" = "ir" [tool.ruff.flake8-pytest-style] fixture-parentheses = false diff --git a/script/scaffold/templates/device_action/integration/device_action.py b/script/scaffold/templates/device_action/integration/device_action.py index a9d77853e553..4732d9bd71ce 100644 --- a/script/scaffold/templates/device_action/integration/device_action.py +++ b/script/scaffold/templates/device_action/integration/device_action.py @@ -13,8 +13,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import entity_registry -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_registry as er from . import DOMAIN @@ -33,7 +32,7 @@ async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device actions for NEW_NAME devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) actions = [] # TODO Read this comment and remove it. @@ -44,7 +43,7 @@ async def async_get_actions( # return zha_device.device_actions # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/script/scaffold/templates/device_condition/integration/device_condition.py b/script/scaffold/templates/device_condition/integration/device_condition.py index cc5ad765885d..00acd23698ad 100644 --- a/script/scaffold/templates/device_condition/integration/device_condition.py +++ b/script/scaffold/templates/device_condition/integration/device_condition.py @@ -14,8 +14,11 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, entity_registry -from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers import ( + condition, + config_validation as cv, + entity_registry as er, +) from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN @@ -23,7 +26,7 @@ from . import DOMAIN # TODO specify your supported condition types. CONDITION_TYPES = {"is_on", "is_off"} -CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( +CONDITION_SCHEMA = cv.DEVICE_CONDITION_BASE_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_TYPE): vol.In(CONDITION_TYPES), @@ -35,11 +38,11 @@ async def async_get_conditions( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device conditions for NEW_NAME devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) conditions = [] # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index a03e27394e28..1fd8810bd88b 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.typing import ConfigType from . import DOMAIN @@ -41,7 +41,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """List device triggers for NEW_NAME devices.""" - registry = entity_registry.async_get(hass) + registry = er.async_get(hass) triggers = [] # TODO Read this comment and remove it. @@ -52,7 +52,7 @@ async def async_get_triggers( # return zha_device.device_triggers # Get all the integrations entities for this device - for entry in entity_registry.async_entries_for_device(registry, device_id): + for entry in er.async_entries_for_device(registry, device_id): if entry.domain != DOMAIN: continue From ae04c5d7737dcd9e8878e1f150fb8d6a474f5be5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 1 Mar 2023 18:42:34 +0100 Subject: [PATCH 0179/1058] Clean up unused and deprecated TLS version setting on MQTT client (#88674) * Cleanup CONF_TLS_VERSION remains * Fix diagnostics tests --- homeassistant/components/mqtt/__init__.py | 2 -- homeassistant/components/mqtt/config_integration.py | 5 ----- homeassistant/components/mqtt/const.py | 1 - tests/components/mqtt/test_diagnostics.py | 1 - tests/components/mqtt/test_discovery.py | 1 - 5 files changed, 10 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index a1b194284c7b..c73eec12449e 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -74,7 +74,6 @@ from .const import ( # noqa: F401 CONF_QOS, CONF_STATE_TOPIC, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_TOPIC, CONF_TRANSPORT, CONF_WILL_MESSAGE, @@ -160,7 +159,6 @@ CONFIG_SCHEMA = vol.Schema( cv.deprecated(CONF_PORT), # Deprecated in HA Core 2022.3 cv.deprecated(CONF_PROTOCOL), # Deprecated in HA Core 2022.11 cv.deprecated(CONF_TLS_INSECURE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_TLS_VERSION), # Deprecated June 2020 cv.deprecated(CONF_USERNAME), # Deprecated in HA Core 2022.3 cv.deprecated(CONF_WILL_MESSAGE), # Deprecated in HA Core 2022.3 CONFIG_SCHEMA_BASE, diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index bbd6861435bb..47f8a7cf492c 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -45,7 +45,6 @@ from .const import ( CONF_DISCOVERY_PREFIX, CONF_KEEPALIVE, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_TRANSPORT, CONF_WILL_MESSAGE, CONF_WS_HEADERS, @@ -72,7 +71,6 @@ DEFAULT_VALUES = { CONF_DISCOVERY_PREFIX: DEFAULT_PREFIX, CONF_PORT: DEFAULT_PORT, CONF_PROTOCOL: DEFAULT_PROTOCOL, - CONF_TLS_VERSION: DEFAULT_TLS_PROTOCOL, CONF_TRANSPORT: DEFAULT_TRANSPORT, CONF_WILL_MESSAGE: DEFAULT_WILL, CONF_KEEPALIVE: DEFAULT_KEEPALIVE, @@ -182,7 +180,6 @@ CONFIG_SCHEMA_ENTRY = vol.Schema( CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG ): str, vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, @@ -214,7 +211,6 @@ CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG ): cv.isfile, vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(SUPPORTED_PROTOCOLS)), vol.Optional(CONF_WILL_MESSAGE): valid_birth_will, vol.Optional(CONF_BIRTH_MESSAGE): valid_birth_will, @@ -236,7 +232,6 @@ DEPRECATED_CONFIG_KEYS = [ CONF_PORT, CONF_PROTOCOL, CONF_TLS_INSECURE, - CONF_TLS_VERSION, CONF_USERNAME, CONF_WILL_MESSAGE, ] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index f7e2cbe5b1b4..bb6b8ed497d3 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -33,7 +33,6 @@ CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" CONF_TLS_INSECURE = "tls_insecure" -CONF_TLS_VERSION = "tls_version" DATA_MQTT = "mqtt" diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index b0ff769e7274..780be7292592 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -24,7 +24,6 @@ default_config = { "keepalive": 60, "port": 1883, "protocol": "3.1.1", - "tls_version": "auto", "transport": "tcp", "ws_headers": {}, "ws_path": "/", diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index ba472cce041a..5cd615e0eb63 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1266,7 +1266,6 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_EMBEDDED", "CONF_KEEPALIVE", "CONF_TLS_INSECURE", - "CONF_TLS_VERSION", "CONF_TRANSPORT", "CONF_WILL_MESSAGE", "CONF_WS_PATH", From 07839cc971bf11bb1034c3d76dc06e44bc107a58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Mar 2023 12:35:53 -0600 Subject: [PATCH 0180/1058] Bump ulid-transform to 0.4.0 (#88982) changelog: https://github.com/bdraco/ulid-transform/compare/v0.3.1...v0.4.0 --- 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 1c35e9910f12..0da05b2d5794 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ requests==2.28.2 scapy==2.5.0 sqlalchemy==2.0.4 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.3.1 +ulid-transform==0.4.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 diff --git a/pyproject.toml b/pyproject.toml index c6e8d6db0e96..a262936da060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.3.1", + "ulid-transform==0.4.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.8.1", diff --git a/requirements.txt b/requirements.txt index d7d65f10b687..76d4f68fbe43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.2 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.3.1 +ulid-transform==0.4.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.8.1 From adb0455bd297571e125fb69ad1d055161fe6f00f Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Wed, 1 Mar 2023 21:19:20 +0100 Subject: [PATCH 0181/1058] Add config flow to EDL21 (#87655) * Added config_flow for edl21. * Added already_configured check. * Added config_flow test * Added setup of the edl21 from configuration.yaml * Ran script.gen_requirements_all * Removed the generated translation file. * Added a deprecation warning when importing from configuration.yaml. * Readded the platform schema. * Added handling of optional name for legacy configuration. * Fixed handling of default value in legacy configuration. * Added duplication check entries created via legacy config. * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Apply suggestions from code review --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 3 +- homeassistant/components/edl21/__init__.py | 17 ++++ homeassistant/components/edl21/config_flow.py | 50 ++++++++++++ homeassistant/components/edl21/const.py | 12 +++ homeassistant/components/edl21/manifest.json | 2 + homeassistant/components/edl21/sensor.py | 44 +++++++--- homeassistant/components/edl21/strings.json | 21 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/edl21/__init__.py | 1 + tests/components/edl21/conftest.py | 15 ++++ tests/components/edl21/test_config_flow.py | 81 +++++++++++++++++++ 13 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/edl21/config_flow.py create mode 100644 homeassistant/components/edl21/const.py create mode 100644 homeassistant/components/edl21/strings.json create mode 100644 tests/components/edl21/__init__.py create mode 100644 tests/components/edl21/conftest.py create mode 100644 tests/components/edl21/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 3c9a1c378a84..5da330bb20a0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -249,7 +249,8 @@ omit = homeassistant/components/ecowitt/sensor.py homeassistant/components/eddystone_temperature/sensor.py homeassistant/components/edimax/switch.py - homeassistant/components/edl21/* + homeassistant/components/edl21/__init__.py + homeassistant/components/edl21/sensor.py homeassistant/components/egardia/* homeassistant/components/eight_sleep/__init__.py homeassistant/components/eight_sleep/binary_sensor.py diff --git a/homeassistant/components/edl21/__init__.py b/homeassistant/components/edl21/__init__.py index f1cd59847444..2ece8517dbdd 100644 --- a/homeassistant/components/edl21/__init__.py +++ b/homeassistant/components/edl21/__init__.py @@ -1 +1,18 @@ """The edl21 component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up EDL21 integration from a config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/edl21/config_flow.py b/homeassistant/components/edl21/config_flow.py new file mode 100644 index 000000000000..b66a988958b9 --- /dev/null +++ b/homeassistant/components/edl21/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for EDL21 integration.""" +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_PORT): str, + } +) + + +class EDL21ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """EDL21 config flow.""" + + VERSION = 1 + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + + self._async_abort_entries_match( + {CONF_SERIAL_PORT: import_config[CONF_SERIAL_PORT]} + ) + return self.async_create_entry( + title=import_config[CONF_NAME] or DEFAULT_TITLE, + data=import_config, + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the user setup step.""" + if user_input is not None: + self._async_abort_entries_match( + {CONF_SERIAL_PORT: user_input[CONF_SERIAL_PORT]} + ) + + return self.async_create_entry( + title=DEFAULT_TITLE, + data=user_input, + ) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=data_schema) diff --git a/homeassistant/components/edl21/const.py b/homeassistant/components/edl21/const.py new file mode 100644 index 000000000000..f57966a00033 --- /dev/null +++ b/homeassistant/components/edl21/const.py @@ -0,0 +1,12 @@ +"""Constants for the EDL21 component.""" +import logging + +LOGGER = logging.getLogger(__package__) + +DOMAIN = "edl21" + +CONF_SERIAL_PORT = "serial_port" + +SIGNAL_EDL21_TELEGRAM = "edl21_telegram" + +DEFAULT_TITLE = "Smart Meter" diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index dc7e861ce837..48bab7d84f13 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -2,7 +2,9 @@ "domain": "edl21", "name": "EDL21", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/edl21", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], "requirements": ["pysml==0.0.8"] diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 497f6867dfab..e34c9c823f61 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -1,8 +1,9 @@ """Support for EDL21 Smart Meters.""" from __future__ import annotations +from collections.abc import Mapping from datetime import timedelta -import logging +from typing import Any from sml import SmlGetListResponse from sml.asyncio import SmlProtocol @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_NAME, DEGREE, @@ -31,15 +33,13 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) 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.util.dt import utcnow -_LOGGER = logging.getLogger(__name__) +from .const import CONF_SERIAL_PORT, DOMAIN, LOGGER, SIGNAL_EDL21_TELEGRAM -DOMAIN = "edl21" -CONF_SERIAL_PORT = "serial_port" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -SIGNAL_EDL21_TELEGRAM = "edl21_telegram" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -269,9 +269,33 @@ async def async_setup_platform( config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up EDL21 sensors via configuration.yaml and show deprecation warning.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.2.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the EDL21 sensor.""" - hass.data[DOMAIN] = EDL21(hass, config, async_add_entities) + hass.data[DOMAIN] = EDL21(hass, config_entry.data, async_add_entities) await hass.data[DOMAIN].connect() @@ -295,14 +319,14 @@ class EDL21: def __init__( self, hass: HomeAssistant, - config: ConfigType, + config: Mapping[str, Any], async_add_entities: AddEntitiesCallback, ) -> None: """Initialize an EDL21 object.""" self._registered_obis: set[tuple[str, str]] = set() self._hass = hass self._async_add_entities = async_add_entities - self._name = config[CONF_NAME] + self._name = config.get(CONF_NAME) self._proto = SmlProtocol(config[CONF_SERIAL_PORT]) self._proto.add_listener(self.event, ["SmlGetListResponse"]) @@ -347,7 +371,7 @@ class EDL21: ) self._registered_obis.add((electricity_id, obis)) elif obis not in self._OBIS_BLACKLIST: - _LOGGER.warning( + LOGGER.warning( "Unhandled sensor %s detected. Please report at %s", obis, "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+edl21%22", @@ -366,7 +390,7 @@ class EDL21: "sensor", DOMAIN, entity.old_unique_id ) if old_entity_id is not None: - _LOGGER.debug( + LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity.old_unique_id, entity.unique_id, diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json new file mode 100644 index 000000000000..284e8229c59b --- /dev/null +++ b/homeassistant/components/edl21/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "title": "Add your EDL21 smart meter", + "data": { + "serial_port": "[%key:common::config_flow::data::usb_path%]" + } + } + } + }, + "issues": { + "deprecated_yaml": { + "title": "EDL21 YAML configuration is being removed", + "description": "Configuring EDL21 using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the EDL21 YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 505554767693..3621c1d48d14 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -107,6 +107,7 @@ FLOWS = { "ecobee", "econet", "ecowitt", + "edl21", "efergy", "eight_sleep", "elgato", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6111681bd35d..a79f06bbd36a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1272,7 +1272,7 @@ "edl21": { "name": "EDL21", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "efergy": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9026fc51e11..a2dead79122a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1431,6 +1431,9 @@ pysmartapp==0.3.3 # homeassistant.components.smartthings pysmartthings==0.7.6 +# homeassistant.components.edl21 +pysml==0.0.8 + # homeassistant.components.snmp pysnmplib==5.0.20 diff --git a/tests/components/edl21/__init__.py b/tests/components/edl21/__init__.py new file mode 100644 index 000000000000..e9b705568921 --- /dev/null +++ b/tests/components/edl21/__init__.py @@ -0,0 +1 @@ +"""Tests for the EDL21 integration.""" diff --git a/tests/components/edl21/conftest.py b/tests/components/edl21/conftest.py new file mode 100644 index 000000000000..dc64659d2b8b --- /dev/null +++ b/tests/components/edl21/conftest.py @@ -0,0 +1,15 @@ +"""Define test fixtures for EDL21.""" + +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.edl21.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/edl21/test_config_flow.py b/tests/components/edl21/test_config_flow.py new file mode 100644 index 000000000000..4dbd69b23718 --- /dev/null +++ b/tests/components/edl21/test_config_flow.py @@ -0,0 +1,81 @@ +"""Test EDL21 config flow.""" + +import pytest + +from homeassistant.components.edl21.const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +VALID_CONFIG = {CONF_SERIAL_PORT: "/dev/ttyUSB1"} +VALID_LEGACY_CONFIG = {CONF_NAME: "My Smart Meter", CONF_SERIAL_PORT: "/dev/ttyUSB1"} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_show_form(hass: HomeAssistant) -> None: + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_TITLE + assert result["data"][CONF_SERIAL_PORT] == VALID_CONFIG[CONF_SERIAL_PORT] + + +async def test_integration_already_exists(hass: HomeAssistant) -> None: + """Test that a new entry must not have the same serial port as an existing entry.""" + + MockConfigEntry( + domain=DOMAIN, + data=VALID_CONFIG, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_create_entry_by_import(hass: HomeAssistant) -> None: + """Test that the import step works.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_LEGACY_CONFIG, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == VALID_LEGACY_CONFIG[CONF_NAME] + assert result["data"][CONF_NAME] == VALID_LEGACY_CONFIG[CONF_NAME] + assert result["data"][CONF_SERIAL_PORT] == VALID_LEGACY_CONFIG[CONF_SERIAL_PORT] + + # Test the import step with an empty string as name + # (the name is optional in the old schema and defaults to "") + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_SERIAL_PORT: "/dev/ttyUSB2", CONF_NAME: ""}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_TITLE + assert result["data"][CONF_NAME] == "" + assert result["data"][CONF_SERIAL_PORT] == "/dev/ttyUSB2" From 19c08bfdd522616899e4b02828a457f46fb4bbfc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Mar 2023 23:44:12 +0100 Subject: [PATCH 0182/1058] Refactor WLED binary sensor test (#88579) --- .../wled/snapshots/test_binary_sensor.ambr | 75 +++++++++++++++++++ tests/components/wled/test_binary_sensor.py | 42 ++++------- 2 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 tests/components/wled/snapshots/test_binary_sensor.ambr diff --git a/tests/components/wled/snapshots/test_binary_sensor.ambr b/tests/components/wled/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..7520ea7a6a61 --- /dev/null +++ b/tests/components/wled/snapshots/test_binary_sensor.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_update_available + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'WLED RGB Light Firmware', + }), + 'context': , + 'entity_id': 'binary_sensor.wled_rgb_light_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_available.1 + 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.wled_rgb_light_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_available.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_binary_sensor.py b/tests/components/wled/test_binary_sensor.py index aa1bec313dfc..eb5faadd5303 100644 --- a/tests/components/wled/test_binary_sensor.py +++ b/tests/components/wled/test_binary_sensor.py @@ -1,56 +1,46 @@ """Tests for the WLED binary sensor platform.""" import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - STATE_OFF, - STATE_ON, - EntityCategory, -) +from homeassistant.const import STATE_OFF 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 pytestmark = pytest.mark.usefixtures("init_integration") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_available( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test the firmware update binary sensor.""" assert (state := hass.states.get("binary_sensor.wled_rgb_light_firmware")) - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE - assert state.state == STATE_ON - assert ATTR_ICON not in state.attributes + assert state == snapshot - assert (entry := entity_registry.async_get("binary_sensor.wled_rgb_light_firmware")) - assert entry.unique_id == "aabbccddeeff_update" - assert entry.entity_category is EntityCategory.DIAGNOSTIC + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize("device_fixture", ["rgb_websocket"]) -async def test_no_update_available( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: +async def test_no_update_available(hass: HomeAssistant) -> None: """Test the update binary sensor. There is no update available.""" assert (state := hass.states.get("binary_sensor.wled_websocket_firmware")) - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.UPDATE assert state.state == STATE_OFF - assert ATTR_ICON not in state.attributes - - assert (entry := entity_registry.async_get("binary_sensor.wled_websocket_firmware")) - assert entry.unique_id == "aabbccddeeff_update" - assert entry.entity_category is EntityCategory.DIAGNOSTIC async def test_disabled_by_default( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test that the binary update sensor is disabled by default.""" - assert hass.states.get("binary_sensor.wled_rgb_light_firmware") is None + assert not hass.states.get("binary_sensor.wled_rgb_light_firmware") assert (entry := entity_registry.async_get("binary_sensor.wled_rgb_light_firmware")) assert entry.disabled From aa92d053170062ed84cb93b2d0e1fd2569220a3d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Mar 2023 03:07:12 +0100 Subject: [PATCH 0183/1058] Bump py-dormakaba-dkey to 1.0.4 (#88992) --- homeassistant/components/dormakaba_dkey/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index b837cf8dfed5..7a4f6b9d905e 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-dormakaba-dkey==1.0.3"] + "requirements": ["py-dormakaba-dkey==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 927ee4d3955a..0796c39ccc4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1430,7 +1430,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.3 +py-dormakaba-dkey==1.0.4 # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2dead79122a..d30b292baca5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1045,7 +1045,7 @@ py-canary==0.5.3 py-cpuinfo==8.0.0 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.3 +py-dormakaba-dkey==1.0.4 # homeassistant.components.melissa py-melissa-climate==2.1.4 From 28e8fae2803a26c7fe830577370ceb9abfbe7e6f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Mar 2023 12:33:04 +0100 Subject: [PATCH 0184/1058] Fix flaky energy tests (#89026) --- tests/components/energy/test_sensor.py | 119 +++++++++++-------------- 1 file changed, 54 insertions(+), 65 deletions(-) diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index 538cc9491847..f5fea153380e 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -2,9 +2,7 @@ import copy from datetime import timedelta from typing import Any -from unittest.mock import patch -from freezegun import freeze_time import pytest from homeassistant.components.energy import data @@ -31,6 +29,8 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done from tests.typing import WebSocketGenerator +TEST_TIME_ADVANCE_INTERVAL = timedelta(milliseconds=10) + @pytest.fixture async def setup_integration(recorder_mock): @@ -44,10 +44,10 @@ async def setup_integration(recorder_mock): @pytest.fixture(autouse=True) -@freeze_time("2022-04-19 07:53:05") -def frozen_time(): +def frozen_time(freezer): """Freeze clock for tests.""" - return + freezer.move_to("2022-04-19 07:53:05") + return freezer def get_statistics_for_entity(statistics_results, entity_id): @@ -139,6 +139,7 @@ async def test_cost_sensor_attributes( ], ) async def test_cost_sensor_price_entity_total_increasing( + frozen_time, setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any], @@ -206,8 +207,7 @@ async def test_cost_sensor_price_entity_total_increasing( ) hass.states.async_set("sensor.energy_price", "1") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost @@ -219,13 +219,12 @@ async def test_cost_sensor_price_entity_total_increasing( # Optional late setup of dependent entities if initial_energy is None: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set( - usage_sensor_entity_id, - "0", - energy_attributes, - ) - await hass.async_block_till_done() + hass.states.async_set( + usage_sensor_entity_id, + "0", + energy_attributes, + ) + await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" @@ -242,6 +241,7 @@ async def test_cost_sensor_price_entity_total_increasing( assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -268,6 +268,7 @@ async def test_cost_sensor_price_entity_total_increasing( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14.5", @@ -285,6 +286,7 @@ async def test_cost_sensor_price_entity_total_increasing( assert statistics["stat"]["sum"] == 19.0 # Energy sensor has a small dip, no reset should be detected + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14", @@ -296,6 +298,7 @@ async def test_cost_sensor_price_entity_total_increasing( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "4", @@ -308,6 +311,7 @@ async def test_cost_sensor_price_entity_total_increasing( last_reset_cost_sensor = state.attributes[ATTR_LAST_RESET] # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -344,6 +348,7 @@ async def test_cost_sensor_price_entity_total_increasing( ) @pytest.mark.parametrize("energy_state_class", ["total", "measurement"]) async def test_cost_sensor_price_entity_total( + frozen_time, setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any], @@ -360,7 +365,9 @@ async def test_cost_sensor_price_entity_total( """Test energy cost price from total type sensor entity.""" def _compile_statistics(_): - return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats + return compile_statistics( + hass, now, now + timedelta(seconds=0.17) + ).platform_stats energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, @@ -413,8 +420,7 @@ async def test_cost_sensor_price_entity_total( ) hass.states.async_set("sensor.energy_price", "1") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost @@ -426,13 +432,12 @@ async def test_cost_sensor_price_entity_total( # Optional late setup of dependent entities if initial_energy is None: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set( - usage_sensor_entity_id, - "0", - {**energy_attributes, **{"last_reset": last_reset}}, - ) - await hass.async_block_till_done() + hass.states.async_set( + usage_sensor_entity_id, + "0", + {**energy_attributes, **{"last_reset": last_reset}}, + ) + await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" @@ -449,6 +454,7 @@ async def test_cost_sensor_price_entity_total( assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -475,6 +481,7 @@ async def test_cost_sensor_price_entity_total( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14.5", @@ -492,6 +499,7 @@ async def test_cost_sensor_price_entity_total( assert statistics["stat"]["sum"] == 19.0 # Energy sensor has a small dip + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14", @@ -503,7 +511,8 @@ async def test_cost_sensor_price_entity_total( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Energy sensor is reset, with initial state at 4kWh, 0 kWh is used as zero-point - last_reset = (now + timedelta(seconds=1)).isoformat() + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) + last_reset = dt_util.utcnow() hass.states.async_set( usage_sensor_entity_id, "4", @@ -516,6 +525,7 @@ async def test_cost_sensor_price_entity_total( last_reset_cost_sensor = state.attributes[ATTR_LAST_RESET] # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -552,6 +562,7 @@ async def test_cost_sensor_price_entity_total( ) @pytest.mark.parametrize("energy_state_class", ["total"]) async def test_cost_sensor_price_entity_total_no_reset( + frozen_time, setup_integration, hass: HomeAssistant, hass_storage: dict[str, Any], @@ -620,8 +631,7 @@ async def test_cost_sensor_price_entity_total_no_reset( ) hass.states.async_set("sensor.energy_price", "1") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get(cost_sensor_entity_id) assert state.state == initial_cost @@ -633,13 +643,12 @@ async def test_cost_sensor_price_entity_total_no_reset( # Optional late setup of dependent entities if initial_energy is None: - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set( - usage_sensor_entity_id, - "0", - energy_attributes, - ) - await hass.async_block_till_done() + hass.states.async_set( + usage_sensor_entity_id, + "0", + energy_attributes, + ) + await hass.async_block_till_done() state = hass.states.get(cost_sensor_entity_id) assert state.state == "0.0" @@ -656,6 +665,7 @@ async def test_cost_sensor_price_entity_total_no_reset( assert entry.hidden_by is er.RegistryEntryHider.INTEGRATION # Energy use bumped to 10 kWh + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "10", @@ -682,6 +692,7 @@ async def test_cost_sensor_price_entity_total_no_reset( assert state.attributes[ATTR_LAST_RESET] == last_reset_cost_sensor # Additional consumption is using the new price + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14.5", @@ -699,6 +710,7 @@ async def test_cost_sensor_price_entity_total_no_reset( assert statistics["stat"]["sum"] == 19.0 # Energy sensor has a small dip + frozen_time.tick(TEST_TIME_ADVANCE_INTERVAL) hass.states.async_set( usage_sensor_entity_id, "14", @@ -759,8 +771,6 @@ async def test_cost_sensor_handle_energy_units( "data": energy_data, } - now = dt_util.utcnow() - # Initial state: 10kWh hass.states.async_set( "sensor.energy_consumption", @@ -768,8 +778,7 @@ async def test_cost_sensor_handle_energy_units( energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "0.0" @@ -833,8 +842,6 @@ async def test_cost_sensor_handle_price_units( "data": energy_data, } - now = dt_util.utcnow() - # Initial state: 10kWh hass.states.async_set("sensor.energy_price", "2", price_attributes) hass.states.async_set( @@ -843,8 +850,7 @@ async def test_cost_sensor_handle_price_units( energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") assert state.state == "0.0" @@ -889,16 +895,13 @@ async def test_cost_sensor_handle_gas( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.gas_consumption", 100, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.gas_consumption_cost") assert state.state == "0.0" @@ -939,16 +942,13 @@ async def test_cost_sensor_handle_gas_kwh( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.gas_consumption", 100, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.gas_consumption_cost") assert state.state == "0.0" @@ -1004,16 +1004,13 @@ async def test_cost_sensor_handle_water( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.water_consumption", 100, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.water_consumption_cost") assert state.state == "0.0" @@ -1065,16 +1062,13 @@ async def test_cost_sensor_wrong_state_class( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.energy_consumption", 10000, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") assert state.state == STATE_UNKNOWN @@ -1130,16 +1124,13 @@ async def test_cost_sensor_state_class_measurement_no_reset( "data": energy_data, } - now = dt_util.utcnow() - hass.states.async_set( "sensor.energy_consumption", 10000, energy_attributes, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.energy_consumption_cost") assert state.state == STATE_UNKNOWN @@ -1176,7 +1167,6 @@ async def test_inherit_source_unique_id( "data": energy_data, } - now = dt_util.utcnow() entity_registry = er.async_get(hass) source_entry = entity_registry.async_get_or_create( "sensor", "test", "123456", suggested_object_id="gas_consumption" @@ -1191,8 +1181,7 @@ async def test_inherit_source_unique_id( }, ) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await setup_integration(hass) + await setup_integration(hass) state = hass.states.get("sensor.gas_consumption_cost") assert state From e5fc2d3f7892d220f95a1379893a2729863767d8 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 2 Mar 2023 22:13:31 +1000 Subject: [PATCH 0185/1058] Add Turn On and Turn Off for Advantage Air climate platform (#88684) * Added Climate On and Climate Off * Add Tests * Fix off and on in zone * Add test assertions for zone HVAC mode --- .../components/advantage_air/climate.py | 60 +++++++++++---- .../components/advantage_air/test_climate.py | 73 ++++++++++++++++--- 2 files changed, 107 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 362701f3b9f3..53a41994fc6e 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -116,6 +116,30 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """Return the current fan modes.""" return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) + async def async_turn_on(self) -> None: + """Set the HVAC State to on.""" + await self.aircon( + { + self.ac_key: { + "info": { + "state": ADVANTAGE_AIR_STATE_ON, + } + } + } + ) + + async def async_turn_off(self) -> None: + """Set the HVAC State to off.""" + await self.aircon( + { + self.ac_key: { + "info": { + "state": ADVANTAGE_AIR_STATE_OFF, + } + } + } + ) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" if hvac_mode == HVACMode.OFF: @@ -181,24 +205,32 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): """Return the target temperature.""" return self._zone["setTemp"] + async def async_turn_on(self) -> None: + """Set the HVAC State to on.""" + await self.aircon( + { + self.ac_key: { + "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}} + } + } + ) + + async def async_turn_off(self) -> None: + """Set the HVAC State to off.""" + await self.aircon( + { + self.ac_key: { + "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} + } + } + ) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" if hvac_mode == HVACMode.OFF: - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_CLOSE}} - } - } - ) + await self.async_turn_off() else: - await self.aircon( - { - self.ac_key: { - "zones": {self.zone_key: {"state": ADVANTAGE_AIR_STATE_OPEN}} - } - } - ) + await self.async_turn_on() async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index f12aec73880d..b3412cb1bc2e 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -8,8 +8,10 @@ from homeassistant.components.advantage_air.climate import ( HASS_HVAC_MODES, ) from homeassistant.components.advantage_air.const import ( + ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, + ADVANTAGE_AIR_STATE_OPEN, ) from homeassistant.components.climate import ( ATTR_FAN_MODE, @@ -19,6 +21,8 @@ from homeassistant.components.climate import ( SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, HVACMode, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE @@ -54,8 +58,6 @@ async def test_climate_async_setup_entry( registry = er.async_get(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test Main Climate Entity entity_id = "climate.ac_one" state = hass.states.get(entity_id) @@ -76,7 +78,6 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -91,7 +92,6 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -105,7 +105,6 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_LOW}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 7 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -119,7 +118,6 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 9 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) @@ -127,6 +125,32 @@ async def test_climate_async_setup_entry( assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + # Test Climate Zone Entity entity_id = "climate.ac_one_zone_open_with_sensor" state = hass.states.get(entity_id) @@ -146,9 +170,11 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 11 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + + assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" @@ -158,9 +184,10 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 13 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" @@ -170,12 +197,37 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 15 assert aioclient_mock.mock_calls[-2][0] == "GET" assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert aioclient_mock.mock_calls[-2][0] == "GET" + assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN + assert aioclient_mock.mock_calls[-1][0] == "GET" + assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + async def test_climate_async_failed_update( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -192,8 +244,6 @@ async def test_climate_async_failed_update( ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - with pytest.raises(HomeAssistantError): await hass.services.async_call( CLIMATE_DOMAIN, @@ -201,6 +251,5 @@ async def test_climate_async_failed_update( {ATTR_ENTITY_ID: ["climate.ac_one"], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 2 assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/setAircon" From 1efc33d4c61053146c6095fab2096b89582e6beb Mon Sep 17 00:00:00 2001 From: Xavier Decuyper Date: Thu, 2 Mar 2023 13:40:22 +0100 Subject: [PATCH 0186/1058] Nuki: show actual device model in device registry (#89017) * Bump pynuki to 1.6.1 (adds friendly device model names) * Nuki: use friendly model name for device registry * Update global dependencies --- homeassistant/components/nuki/__init__.py | 2 +- homeassistant/components/nuki/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index f1c3d7149612..9504d38c932b 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -215,7 +215,7 @@ class NukiEntity(CoordinatorEntity[NukiCoordinator], Generic[_NukiDeviceT]): "identifiers": {(DOMAIN, parse_id(self._nuki_device.nuki_id))}, "name": self._nuki_device.name, "manufacturer": "Nuki Home Solutions GmbH", - "model": self._nuki_device.device_type_str.capitalize(), + "model": self._nuki_device.device_model_str.capitalize(), "sw_version": self._nuki_device.firmware_version, "via_device": (DOMAIN, self.coordinator.bridge_id), } diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index ac69f97a9bea..e6b741d44293 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuki", "iot_class": "local_polling", "loggers": ["pynuki"], - "requirements": ["pynuki==1.6.0"] + "requirements": ["pynuki==1.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0796c39ccc4f..57291a896061 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1816,7 +1816,7 @@ pynina==0.2.0 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.0 +pynuki==1.6.1 # homeassistant.components.nut pynut2==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d30b292baca5..e38918ee4688 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1308,7 +1308,7 @@ pynina==0.2.0 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.0 +pynuki==1.6.1 # homeassistant.components.nut pynut2==2.1.2 From ec32b934a56d2c3bac3a082e508cb4b8d73e9739 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 2 Mar 2023 15:40:46 +0100 Subject: [PATCH 0187/1058] Update orjson to 3.8.7 (#89037) --- 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 0da05b2d5794..b1c74f4207b0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ ifaddr==0.1.7 janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 -orjson==3.8.6 +orjson==3.8.7 paho-mqtt==1.6.1 pillow==9.4.0 pip>=21.0,<23.1 diff --git a/pyproject.toml b/pyproject.toml index a262936da060..10fa38be35e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==39.0.1", # pyOpenSSL 23.0.0 is required to work with cryptography 39+ "pyOpenSSL==23.0.0", - "orjson==3.8.6", + "orjson==3.8.7", "pip>=21.0,<23.1", "python-slugify==4.0.1", "pyyaml==6.0", diff --git a/requirements.txt b/requirements.txt index 76d4f68fbe43..478b8a64d500 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.1.8 PyJWT==2.5.0 cryptography==39.0.1 pyOpenSSL==23.0.0 -orjson==3.8.6 +orjson==3.8.7 pip>=21.0,<23.1 python-slugify==4.0.1 pyyaml==6.0 From fd4d79d24cf96e7fad6a562d6b16b8227d8b21c6 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 2 Mar 2023 16:10:26 +0100 Subject: [PATCH 0188/1058] Update frontend to 20230302.0 (#89042) --- 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 9cd10bb4d0a3..c09f2d501c62 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==20230301.0"] + "requirements": ["home-assistant-frontend==20230302.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b1c74f4207b0..cd2950c9641e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230301.0 +home-assistant-frontend==20230302.0 home-assistant-intents==2023.2.28 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 57291a896061..a007390b9a4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230301.0 +home-assistant-frontend==20230302.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e38918ee4688..ddcad6f00280 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230301.0 +home-assistant-frontend==20230302.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From f69aa7ad9ddbeea6b11b9b480529f266ebfc8a82 Mon Sep 17 00:00:00 2001 From: Toni Juvani Date: Thu, 2 Mar 2023 17:11:34 +0200 Subject: [PATCH 0189/1058] Update pyTibber to 0.27.0 (#86940) * Update pyTibber to 0.27.0 * Handle new exceptions --- homeassistant/components/tibber/__init__.py | 15 ++++++++------- homeassistant/components/tibber/config_flow.py | 8 ++++++-- homeassistant/components/tibber/manifest.json | 2 +- homeassistant/components/tibber/sensor.py | 17 ++++++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 31 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 4d9c05606828..6bd68e17c4d2 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -53,17 +53,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await tibber_connection.update_info() - if not tibber_connection.name: - raise ConfigEntryNotReady("Could not fetch Tibber data.") - except asyncio.TimeoutError as err: - raise ConfigEntryNotReady from err - except aiohttp.ClientError as err: - _LOGGER.error("Error connecting to Tibber: %s ", err) - return False + except ( + asyncio.TimeoutError, + aiohttp.ClientError, + tibber.RetryableHttpException, + ) as err: + raise ConfigEntryNotReady("Unable to connect") from err except tibber.InvalidLogin as exp: _LOGGER.error("Failed to login. %s", exp) return False + except tibber.FatalHttpException: + return False await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index d0adc0391abf..b5cb4486cc93 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -44,10 +44,14 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await tibber_connection.update_info() except asyncio.TimeoutError: errors[CONF_ACCESS_TOKEN] = "timeout" - except aiohttp.ClientError: - errors[CONF_ACCESS_TOKEN] = "cannot_connect" except tibber.InvalidLogin: errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + except ( + aiohttp.ClientError, + tibber.RetryableHttpException, + tibber.FatalHttpException, + ): + errors[CONF_ACCESS_TOKEN] = "cannot_connect" if errors: return self.async_show_form( diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 0e23729df725..e716192b8b4a 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.26.13"] + "requirements": ["pyTibber==0.27.0"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 5f375ee22ed7..7c563208720a 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -44,6 +44,7 @@ from homeassistant.helpers.entity_registry import async_get as async_get_entity_ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, + UpdateFailed, ) from homeassistant.util import Throttle, dt as dt_util @@ -559,6 +560,8 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): class TibberDataCoordinator(DataUpdateCoordinator[None]): """Handle Tibber data and insert statistics.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, tibber_connection: tibber.Tibber) -> None: """Initialize the data handler.""" super().__init__( @@ -571,9 +574,17 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Update data via API.""" - await self._tibber_connection.fetch_consumption_data_active_homes() - await self._tibber_connection.fetch_production_data_active_homes() - await self._insert_statistics() + try: + await self._tibber_connection.fetch_consumption_data_active_homes() + await self._tibber_connection.fetch_production_data_active_homes() + await self._insert_statistics() + except tibber.RetryableHttpException as err: + raise UpdateFailed(f"Error communicating with API ({err.status})") from err + except tibber.FatalHttpException: + # Fatal error. Reload config entry to show correct error. + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) async def _insert_statistics(self) -> None: """Insert Tibber statistics.""" diff --git a/requirements_all.txt b/requirements_all.txt index a007390b9a4a..778258f4e184 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1473,7 +1473,7 @@ pyRFXtrx==0.30.1 pySwitchmate==0.5.1 # homeassistant.components.tibber -pyTibber==0.26.13 +pyTibber==0.27.0 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ddcad6f00280..6ebbbe753663 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1076,7 +1076,7 @@ pyMetno==0.9.0 pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.26.13 +pyTibber==0.27.0 # homeassistant.components.dlink pyW215==0.7.0 From e849878a48d5aec2277483e394808abf2a1489d9 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 2 Mar 2023 16:13:02 +0100 Subject: [PATCH 0190/1058] Fix KNX Keyfile upload (#89029) * Fix KNX Keyfile upload * use shutil.move instead --- homeassistant/components/knx/config_flow.py | 11 ++++++++--- tests/components/knx/test_config_flow.py | 11 ++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index ec79dbc5f9aa..85e23cbe5474 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import AsyncGenerator from pathlib import Path +import shutil from typing import Any, Final import voluptuous as vol @@ -549,9 +550,12 @@ class KNXCommonFlow(ABC, FlowHandler): ), None, ) + _tunnel_identifier = selected_tunnel_ia or self.new_entry_data.get( + CONF_HOST + ) + _tunnel_suffix = f" @ {_tunnel_identifier}" if _tunnel_identifier else "" self.new_title = ( - f"{'Secure ' if _if_user_id else ''}" - f"Tunneling @ {selected_tunnel_ia or self.new_entry_data[CONF_HOST]}" + f"{'Secure ' if _if_user_id else ''}Tunneling{_tunnel_suffix}" ) return self.finish_flow() @@ -708,7 +712,8 @@ class KNXCommonFlow(ABC, FlowHandler): else: dest_path = Path(self.hass.config.path(STORAGE_DIR, DOMAIN)) dest_path.mkdir(exist_ok=True) - file_path.rename(dest_path / DEFAULT_KNX_KEYRING_FILENAME) + dest_file = dest_path / DEFAULT_KNX_KEYRING_FILENAME + shutil.move(file_path, dest_file) return keyring, errors keyring, errors = await self.hass.async_add_executor_job(_process_upload) diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 4ac6a366119b..054d78447144 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -77,16 +77,17 @@ def patch_file_upload(return_value=FIXTURE_KEYRING, side_effect=None): side_effect=side_effect, ), patch( "pathlib.Path.mkdir" - ) as mkdir_mock: - file_path_mock = Mock() - file_upload_mock.return_value.__enter__.return_value = file_path_mock + ) as mkdir_mock, patch( + "shutil.move" + ) as shutil_move_mock: + file_upload_mock.return_value.__enter__.return_value = Mock() yield return_value if side_effect: mkdir_mock.assert_not_called() - file_path_mock.rename.assert_not_called() + shutil_move_mock.assert_not_called() else: mkdir_mock.assert_called_once() - file_path_mock.rename.assert_called_once() + shutil_move_mock.assert_called_once() def _gateway_descriptor( From eebcf70b41aec5b4614051471119bf8ddf8628df Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Mar 2023 19:01:05 +0100 Subject: [PATCH 0191/1058] Re-enable Ruff D411 (#89035) --- homeassistant/components/esphome/bluetooth/client.py | 4 ++++ pyproject.toml | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 7eb38edbf444..545a436ee8b9 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -234,6 +234,7 @@ class ESPHomeClient(BaseBleakClient): Keyword Args: timeout (float): Timeout for required ``BleakScanner.find_device_by_address`` call. Defaults to 10.0. + Returns: Boolean representing connection status. """ @@ -504,6 +505,7 @@ class ESPHomeClient(BaseBleakClient): The characteristic to read from, specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. + Returns: (bytearray) The read data. """ @@ -519,6 +521,7 @@ class ESPHomeClient(BaseBleakClient): Args: handle (int): The handle of the descriptor to read from. + Returns: (bytearray) The read data. """ @@ -583,6 +586,7 @@ class ESPHomeClient(BaseBleakClient): def callback(sender: int, data: bytearray): print(f"{sender}: {data}") client.start_notify(char_uuid, callback) + Args: characteristic (BleakGATTCharacteristic): The characteristic to activate notifications/indications on a diff --git a/pyproject.toml b/pyproject.toml index 10fa38be35e7..c0c23166880b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -271,7 +271,6 @@ ignore = [ "D404", # First word of the docstring should not be This "D406", # Section name should end with a newline "D407", # Section name underlining - "D411", # Missing blank line before section "E501", # line too long "E731", # do not assign a lambda expression, use a def ] From 5cab63c5b842752d4fa712018c2797e79eac64ce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Mar 2023 19:01:50 +0100 Subject: [PATCH 0192/1058] Fix lingering task in debounce tests (#89019) * Fix lingering task in debounce tests * Correct fix * Use async_fire_time_changed --- tests/helpers/test_debounce.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index eb1cdf6db765..4c25413d9fb0 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -1,8 +1,12 @@ """Tests for debounce.""" +from datetime import timedelta from unittest.mock import AsyncMock from homeassistant.core import HomeAssistant from homeassistant.helpers import debounce +from homeassistant.util.dt import utcnow + +from ..common import async_fire_time_changed async def test_immediate_works(hass: HomeAssistant) -> None: @@ -41,7 +45,8 @@ async def test_immediate_works(hass: HomeAssistant) -> None: # Call and let timer run out await debouncer.async_call() assert len(calls) == 2 - await debouncer._handle_timer_finish() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() assert len(calls) == 2 assert debouncer._timer_task is None assert debouncer._execute_at_end_of_timer is False @@ -89,7 +94,8 @@ async def test_not_immediate_works(hass: HomeAssistant) -> None: # Call and let timer run out await debouncer.async_call() assert len(calls) == 0 - await debouncer._handle_timer_finish() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() assert len(calls) == 1 assert debouncer._timer_task is not None assert debouncer._execute_at_end_of_timer is False @@ -150,7 +156,8 @@ async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> Non await debouncer.async_call() assert len(calls) == 2 assert calls == [1, 2] - await debouncer._handle_timer_finish() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() assert len(calls) == 2 assert calls == [1, 2] assert debouncer._timer_task is None From 8968ed1c47314db4b9ee7eeb302c92c4e71915a3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 2 Mar 2023 20:20:26 +0100 Subject: [PATCH 0193/1058] Fix check on non numeric custom sensor device classes (#89052) * Custom device classes are not numeric * Update homeassistant/components/sensor/__init__.py Co-authored-by: Paulus Schoutsen * Add test * Update homeassistant/components/sensor/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: Paulus Schoutsen Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/sensor/__init__.py | 11 ++++-- tests/components/sensor/test_init.py | 41 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index fd86024fbdf8..1812f41693d8 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -271,15 +271,20 @@ class SensorEntity(Entity): @property def _numeric_state_expected(self) -> bool: """Return true if the sensor must be numeric.""" + # Note: the order of the checks needs to be kept aligned + # with the checks in `state` property. + device_class = try_parse_enum(SensorDeviceClass, self.device_class) + if device_class in NON_NUMERIC_DEVICE_CLASSES: + return False if ( self.state_class is not None or self.native_unit_of_measurement is not None or self.suggested_display_precision is not None ): return True - # Sensors with custom device classes are not considered numeric - device_class = try_parse_enum(SensorDeviceClass, self.device_class) - return device_class not in {None, *NON_NUMERIC_DEVICE_CLASSES} + # Sensors with custom device classes will have the device class + # converted to None and are not considered numeric + return device_class is not None @property def options(self) -> list[str] | None: diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 7d96d51d5ca0..8be15f1c7cd1 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -205,6 +205,47 @@ async def test_datetime_conversion( assert state.state == test_timestamp.isoformat() +async def test_a_sensor_with_a_non_numeric_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + enable_custom_integrations: None, +) -> None: + """Test that a sensor with a non numeric device class will be non numeric. + + A non numeric sensor with a valid device class should never be + handled as numeric because it has a device class. + """ + test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=timezone.utc) + test_local_timestamp = test_timestamp.astimezone( + dt_util.get_time_zone("Europe/Amsterdam") + ) + + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", + native_value=test_local_timestamp, + native_unit_of_measurement="", + device_class=SensorDeviceClass.TIMESTAMP, + ) + + platform.ENTITIES["1"] = platform.MockSensor( + name="Test", + native_value=test_local_timestamp, + state_class="", + device_class=SensorDeviceClass.TIMESTAMP, + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(platform.ENTITIES["0"].entity_id) + assert state.state == test_timestamp.isoformat() + + state = hass.states.get(platform.ENTITIES["1"].entity_id) + assert state.state == test_timestamp.isoformat() + + @pytest.mark.parametrize( ("device_class", "state_value", "provides"), [ From 7365522d1fa8fb9e0f466c2ea1ef9fe0fe8a1a27 Mon Sep 17 00:00:00 2001 From: Guy Martin Date: Thu, 2 Mar 2023 19:43:11 -0500 Subject: [PATCH 0194/1058] Add matching on quirk_classes to zha (#87653) * Add matching on quirk_classes. * Add and fix tests for matching on quirk_classes. * Black fix. * Add a unit test to validate quirk classes. --- .../components/zha/core/channels/__init__.py | 5 + .../components/zha/core/discovery.py | 17 +- .../components/zha/core/registries.py | 60 ++++++-- tests/components/zha/test_registries.py | 145 ++++++++++++++++-- 4 files changed, 196 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 149b733be397..a708e65a07a7 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -239,6 +239,11 @@ class ChannelPool: """Return device model.""" return self._channels.zha_device.model + @property + def quirk_class(self) -> str: + """Return device quirk class.""" + return self._channels.zha_device.quirk_class + @property def skip_configuration(self) -> bool: """Return True if device does not require channel configuration.""" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index eb7dd81e381d..d256b98cfb11 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -95,7 +95,11 @@ class ProbeEndpoint: if component and component in zha_const.PLATFORMS: channels = channel_pool.unclaimed_channels() entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( - component, channel_pool.manufacturer, channel_pool.model, channels + component, + channel_pool.manufacturer, + channel_pool.model, + channels, + channel_pool.quirk_class, ) if entity_class is None: return @@ -145,7 +149,11 @@ class ProbeEndpoint: unique_id = f"{ep_channels.unique_id}-{channel.cluster.cluster_id}" entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( - component, ep_channels.manufacturer, ep_channels.model, channel_list + component, + ep_channels.manufacturer, + ep_channels.model, + channel_list, + ep_channels.quirk_class, ) if entity_class is None: return @@ -190,12 +198,14 @@ class ProbeEndpoint: channel_pool.manufacturer, channel_pool.model, list(channel_pool.all_channels.values()), + channel_pool.quirk_class, ) else: matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( channel_pool.manufacturer, channel_pool.model, channel_pool.unclaimed_channels(), + channel_pool.quirk_class, ) channel_pool.claim_channels(claimed) @@ -210,8 +220,7 @@ class ProbeEndpoint: for component, ent_n_chan_list in matches.items(): for entity_and_channel in ent_n_chan_list: if component == cmpt_by_dev_type: - # for well known device types, like thermostats - # we'll take only 1st class + # for well known device types, like thermostats we'll take only 1st class channel_pool.async_new_entity( component, entity_and_channel.entity_class, diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 6b99d412688d..a7504ae7a96d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -93,9 +93,7 @@ DEVICE_CLASS = { zigpy.profiles.zha.DeviceType.ON_OFF_PLUG_IN_UNIT: Platform.SWITCH, zigpy.profiles.zha.DeviceType.SHADE: Platform.COVER, zigpy.profiles.zha.DeviceType.SMART_PLUG: Platform.SWITCH, - zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: ( - Platform.ALARM_CONTROL_PANEL - ), + zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: Platform.ALARM_CONTROL_PANEL, zigpy.profiles.zha.DeviceType.IAS_WARNING_DEVICE: Platform.SIREN, }, zigpy.profiles.zll.PROFILE_ID: { @@ -146,13 +144,17 @@ class MatchRule: aux_channels: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) + quirk_classes: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable + ) @property def weight(self) -> int: """Return the weight of the matching rule. - More specific matches should be preferred over less specific. Model matching - rules have a priority over manufacturer matching rules and rules matching a + More specific matches should be preferred over less specific. Quirk class + matching rules have priority over model matching rules + and have a priority over manufacturer matching rules and rules matching a single model/manufacturer get a better priority over rules matching multiple models/manufacturers. And any model or manufacturers matching rules get better priority over rules matching only channels. @@ -160,6 +162,11 @@ class MatchRule: multiple channels a better priority over rules matching a single channel. """ weight = 0 + if self.quirk_classes: + weight += 501 - ( + 1 if callable(self.quirk_classes) else len(self.quirk_classes) + ) + if self.models: weight += 401 - (1 if callable(self.models) else len(self.models)) @@ -187,15 +194,21 @@ class MatchRule: claimed.extend([ch for ch in channel_pool if ch.name in self.aux_channels]) return claimed - def strict_matched(self, manufacturer: str, model: str, channels: list) -> bool: + def strict_matched( + self, manufacturer: str, model: str, channels: list, quirk_class: str + ) -> bool: """Return True if this device matches the criteria.""" - return all(self._matched(manufacturer, model, channels)) + return all(self._matched(manufacturer, model, channels, quirk_class)) - def loose_matched(self, manufacturer: str, model: str, channels: list) -> bool: + def loose_matched( + self, manufacturer: str, model: str, channels: list, quirk_class: str + ) -> bool: """Return True if this device matches the criteria.""" - return any(self._matched(manufacturer, model, channels)) + return any(self._matched(manufacturer, model, channels, quirk_class)) - def _matched(self, manufacturer: str, model: str, channels: list) -> list: + def _matched( + self, manufacturer: str, model: str, channels: list, quirk_class: str + ) -> list: """Return a list of field matches.""" if not any(attr.asdict(self).values()): return [False] @@ -221,6 +234,12 @@ class MatchRule: else: matches.append(model in self.models) + if self.quirk_classes: + if callable(self.quirk_classes): + matches.append(self.quirk_classes(quirk_class)) + else: + matches.append(quirk_class in self.quirk_classes) + return matches @@ -261,12 +280,13 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], + quirk_class: str, default: type[ZhaEntity] | None = None, ) -> tuple[type[ZhaEntity] | None, list[ZigbeeChannel]]: """Match a ZHA Channels to a ZHA Entity class.""" matches = self._strict_registry[component] for match in sorted(matches, key=lambda x: x.weight, reverse=True): - if match.strict_matched(manufacturer, model, channels): + if match.strict_matched(manufacturer, model, channels, quirk_class): claimed = match.claim_channels(channels) return self._strict_registry[component][match], claimed @@ -277,6 +297,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], + quirk_class: str, ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) @@ -285,7 +306,7 @@ class ZHAEntityRegistry: for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) for match in sorted_matches: - if match.strict_matched(manufacturer, model, channels): + if match.strict_matched(manufacturer, model, channels, quirk_class): claimed = match.claim_channels(channels) for ent_class in stop_match_groups[stop_match_grp][match]: ent_n_channels = EntityClassAndChannels(ent_class, claimed) @@ -301,6 +322,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], + quirk_class: str, ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) @@ -312,7 +334,7 @@ class ZHAEntityRegistry: for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) for match in sorted_matches: - if match.strict_matched(manufacturer, model, channels): + if match.strict_matched(manufacturer, model, channels, quirk_class): claimed = match.claim_channels(channels) for ent_class in stop_match_groups[stop_match_grp][match]: ent_n_channels = EntityClassAndChannels(ent_class, claimed) @@ -335,11 +357,17 @@ class ZHAEntityRegistry: manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, aux_channels: Callable | set[str] | str | None = None, + quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" rule = MatchRule( - channel_names, generic_ids, manufacturers, models, aux_channels + channel_names, + generic_ids, + manufacturers, + models, + aux_channels, + quirk_classes, ) def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: @@ -361,6 +389,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str | None = None, aux_channels: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, + quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -370,6 +399,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_channels, + quirk_classes, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: @@ -394,6 +424,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str | None = None, aux_channels: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, + quirk_classes: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -403,6 +434,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_channels, + quirk_classes, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index db7aa2791cfd..24cd7a5785fd 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -1,13 +1,16 @@ """Test ZHA registries.""" +import inspect from unittest import mock import pytest +import zhaquirks import homeassistant.components.zha.core.registries as registries from homeassistant.helpers import entity_registry as er MANUFACTURER = "mock manufacturer" MODEL = "mock model" +QUIRK_CLASS = "mock.class" @pytest.fixture @@ -16,6 +19,7 @@ def zha_device(): dev = mock.MagicMock() dev.manufacturer = MANUFACTURER dev.model = MODEL + dev.quirk_class = QUIRK_CLASS return dev @@ -70,6 +74,16 @@ def channels(channel): (registries.MatchRule(models="no match"), False), (registries.MatchRule(models=MODEL, aux_channels="aux_channel"), True), (registries.MatchRule(models="no match", aux_channels="aux_channel"), False), + (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), + (registries.MatchRule(quirk_classes="no match"), False), + ( + registries.MatchRule(quirk_classes=QUIRK_CLASS, aux_channels="aux_channel"), + True, + ), + ( + registries.MatchRule(quirk_classes="no match", aux_channels="aux_channel"), + False, + ), # match everything ( registries.MatchRule( @@ -77,6 +91,7 @@ def channels(channel): channel_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, + quirk_classes=QUIRK_CLASS, ), True, ), @@ -124,11 +139,35 @@ def channels(channel): registries.MatchRule(channel_names="on_off", models=lambda x: x != MODEL), False, ), + ( + registries.MatchRule( + channel_names="on_off", quirk_classes={"random quirk", QUIRK_CLASS} + ), + True, + ), + ( + registries.MatchRule( + channel_names="on_off", quirk_classes={"random quirk", "another quirk"} + ), + False, + ), + ( + registries.MatchRule( + channel_names="on_off", quirk_classes=lambda x: x == QUIRK_CLASS + ), + True, + ), + ( + registries.MatchRule( + channel_names="on_off", quirk_classes=lambda x: x != QUIRK_CLASS + ), + False, + ), ], ) def test_registry_matching(rule, matched, channels) -> None: """Test strict rule matching.""" - assert rule.strict_matched(MANUFACTURER, MODEL, channels) is matched + assert rule.strict_matched(MANUFACTURER, MODEL, channels, QUIRK_CLASS) is matched @pytest.mark.parametrize( @@ -197,6 +236,8 @@ def test_registry_matching(rule, matched, channels) -> None: (registries.MatchRule(manufacturers=MANUFACTURER), True), (registries.MatchRule(models=MODEL), True), (registries.MatchRule(models="no match"), False), + (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), + (registries.MatchRule(quirk_classes="no match"), False), # match everything ( registries.MatchRule( @@ -204,6 +245,7 @@ def test_registry_matching(rule, matched, channels) -> None: channel_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, + quirk_classes=QUIRK_CLASS, ), True, ), @@ -211,7 +253,7 @@ def test_registry_matching(rule, matched, channels) -> None: ) def test_registry_loose_matching(rule, matched, channels) -> None: """Test loose rule matching.""" - assert rule.loose_matched(MANUFACTURER, MODEL, channels) is matched + assert rule.loose_matched(MANUFACTURER, MODEL, channels, QUIRK_CLASS) is matched def test_match_rule_claim_channels_color(channel) -> None: @@ -264,18 +306,24 @@ def entity_registry(): @pytest.mark.parametrize( - ("manufacturer", "model", "match_name"), + ("manufacturer", "model", "quirk_class", "match_name"), ( - ("random manufacturer", "random model", "OnOff"), - ("random manufacturer", MODEL, "OnOffModel"), - (MANUFACTURER, "random model", "OnOffManufacturer"), - (MANUFACTURER, MODEL, "OnOffModelManufacturer"), - (MANUFACTURER, "some model", "OnOffMultimodel"), + ("random manufacturer", "random model", "random.class", "OnOff"), + ("random manufacturer", MODEL, "random.class", "OnOffModel"), + (MANUFACTURER, "random model", "random.class", "OnOffManufacturer"), + ("random manufacturer", "random model", QUIRK_CLASS, "OnOffQuirk"), + (MANUFACTURER, MODEL, "random.class", "OnOffModelManufacturer"), + (MANUFACTURER, "some model", "random.class", "OnOffMultimodel"), ), ) def test_weighted_match( - channel, entity_registry: er.EntityRegistry, manufacturer, model, match_name -) -> None: + channel, + entity_registry: er.EntityRegistry, + manufacturer, + model, + quirk_class, + match_name, +): """Test weightedd match.""" s = mock.sentinel @@ -308,11 +356,17 @@ def test_weighted_match( class OnOffModelManufacturer: pass + @entity_registry.strict_match( + s.component, channel_names="on_off", quirk_classes=QUIRK_CLASS + ) + class OnOffQuirk: + pass + ch_on_off = channel("on_off", 6) ch_level = channel("level", 8) match, claimed = entity_registry.get_entity( - s.component, manufacturer, model, [ch_on_off, ch_level] + s.component, manufacturer, model, [ch_on_off, ch_level], quirk_class ) assert match.__name__ == match_name @@ -335,7 +389,10 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None ch_illuminati = channel("illuminance", 0x0401) match, claimed = entity_registry.get_multi_entity( - "manufacturer", "model", channels=[ch_se, ch_illuminati] + "manufacturer", + "model", + channels=[ch_se, ch_illuminati], + quirk_class="quirk_class", ) assert s.binary_sensor in match @@ -360,7 +417,10 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None pass match, claimed = entity_registry.get_multi_entity( - "manufacturer", "model", channels={ch_se, ch_illuminati} + "manufacturer", + "model", + channels={ch_se, ch_illuminati}, + quirk_class="quirk_class", ) assert s.binary_sensor in match @@ -373,3 +433,62 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None assert {cls.entity_class.__name__ for cls in match[s.component]} == { SmartEnergySensor1.__name__ } + + +def test_quirk_classes(): + """Make sure that quirk_classes in components matches are valid.""" + + def find_quirk_class(base_obj, quirk_mod, quirk_cls): + """Find a specific quirk class.""" + mods = dict(inspect.getmembers(base_obj, inspect.ismodule)) + + # Check if we have found the right module + if quirk_mod in mods: + # If so, look for the class + clss = dict(inspect.getmembers(mods[quirk_mod], inspect.isclass)) + if quirk_cls in clss: + # Quirk class found + return True + + else: + # Recurse into other modules + for mod in mods: + if not mods[mod].__name__.startswith("zhaquirks."): + continue + if find_quirk_class(mods[mod], quirk_mod, quirk_cls): + return True + return False + + def quirk_class_validator(value): + """Validate quirk classes during self test.""" + if callable(value): + # Callables cannot be tested + return + + if isinstance(value, (frozenset, set, list)): + for v in value: + # Unpack the value if needed + quirk_class_validator(v) + return + + quirk_tok = value.split(".") + if len(quirk_tok) != 2: + # quirk_class is always __module__.__class__ + raise ValueError(f"Invalid quirk class : '{value}'") + + if not find_quirk_class(zhaquirks, quirk_tok[0], quirk_tok[1]): + raise ValueError(f"Quirk class '{value}' does not exists.") + + for component in registries.ZHA_ENTITIES._strict_registry.items(): + for rule in component[1].items(): + quirk_class_validator(rule[0].quirk_classes) + + for component in registries.ZHA_ENTITIES._multi_entity_registry.items(): + for item in component[1].items(): + for rule in item[1].items(): + quirk_class_validator(rule[0].quirk_classes) + + for component in registries.ZHA_ENTITIES._config_diagnostic_entity_registry.items(): + for item in component[1].items(): + for rule in item[1].items(): + quirk_class_validator(rule[0].quirk_classes) From 48b93e03ee8b2d929c35e5b53fb6ab9d6bbb96da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Mar 2023 16:31:12 -1000 Subject: [PATCH 0195/1058] Cache transient templates compiles provided via api (#89065) * Cache transient templates compiles provided via api partially fixes #89047 (there is more going on here) * add a bit more coverage just to be sure * switch method * Revert "switch method" This reverts commit 0e9e1c8cbe8753159f4fd6775cdc9cf217d66f0e. * tweak * hold hass * empty for github flakey --- homeassistant/components/api/__init__.py | 9 +++- .../components/mobile_app/webhook.py | 10 +++- .../components/websocket_api/commands.py | 9 +++- tests/components/api/test_init.py | 46 +++++++++++++++++++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 56a07a6bcf07..5c0a60ecef7f 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,5 +1,6 @@ """Rest API for Home Assistant.""" import asyncio +from functools import lru_cache from http import HTTPStatus import logging @@ -350,6 +351,12 @@ class APIComponentsView(HomeAssistantView): return self.json(request.app["hass"].config.components) +@lru_cache +def _cached_template(template_str: str, hass: ha.HomeAssistant) -> template.Template: + """Return a cached template.""" + return template.Template(template_str, hass) + + class APITemplateView(HomeAssistantView): """View to handle Template requests.""" @@ -362,7 +369,7 @@ class APITemplateView(HomeAssistantView): raise Unauthorized() try: data = await request.json() - tpl = template.Template(data["template"], request.app["hass"]) + tpl = _cached_template(data["template"], request.app["hass"]) return tpl.async_render(variables=data.get("variables"), parse_result=False) except (ValueError, TemplateError) as ex: return self.json_message( diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index c7fc375008ac..90e244aaf06a 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine from contextlib import suppress -from functools import wraps +from functools import lru_cache, wraps from http import HTTPStatus import logging import secrets @@ -365,6 +365,12 @@ async def webhook_stream_camera( return webhook_response(resp, registration=config_entry.data) +@lru_cache +def _cached_template(template_str: str, hass: HomeAssistant) -> template.Template: + """Return a cached template.""" + return template.Template(template_str, hass) + + @WEBHOOK_COMMANDS.register("render_template") @validate_schema( { @@ -381,7 +387,7 @@ async def webhook_render_template( resp = {} for key, item in data.items(): try: - tpl = template.Template(item[ATTR_TEMPLATE], hass) + tpl = _cached_template(item[ATTR_TEMPLATE], hass) resp[key] = tpl.async_render(item.get(ATTR_TEMPLATE_VARIABLES)) except TemplateError as ex: resp[key] = {"error": str(ex)} diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index e8008eb49b64..fa5c6aac2944 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress import datetime as dt +from functools import lru_cache import json from typing import Any, cast @@ -424,6 +425,12 @@ def handle_ping( connection.send_message(pong_message(msg["id"])) +@lru_cache +def _cached_template(template_str: str, hass: HomeAssistant) -> template.Template: + """Return a cached template.""" + return template.Template(template_str, hass) + + @decorators.websocket_command( { vol.Required("type"): "render_template", @@ -440,7 +447,7 @@ async def handle_render_template( ) -> None: """Handle render_template command.""" template_str = msg["template"] - template_obj = template.Template(template_str, hass) + template_obj = _cached_template(template_str, hass) variables = msg.get("variables") timeout = msg.get("timeout") info = None diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 570bb980aba2..61da000fc077 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -349,6 +349,52 @@ async def test_api_template(hass: HomeAssistant, mock_api_client: TestClient) -> assert body == "10" + hass.states.async_set("sensor.temperature", 20) + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": "{{ states.sensor.temperature.state }}"}, + ) + + body = await resp.text() + + assert body == "20" + + hass.states.async_remove("sensor.temperature") + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": "{{ states.sensor.temperature.state }}"}, + ) + + body = await resp.text() + + assert body == "" + + +async def test_api_template_cached( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test the template API uses the cache.""" + hass.states.async_set("sensor.temperature", 30) + + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": "{{ states.sensor.temperature.state }}"}, + ) + + body = await resp.text() + + assert body == "30" + + hass.states.async_set("sensor.temperature", 40) + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json={"template": "{{ states.sensor.temperature.state }}"}, + ) + + body = await resp.text() + + assert body == "40" + async def test_api_template_error( hass: HomeAssistant, mock_api_client: TestClient From a689ce728381e254062057d17611c73a2c465ea1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 2 Mar 2023 23:21:40 -0500 Subject: [PATCH 0196/1058] Remove unused constant (#89071) --- homeassistant/components/zwave_js/api.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 091de1949eb3..91b1e2a71574 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -120,9 +120,6 @@ OPTED_IN = "opted_in" SECURITY_CLASSES = "security_classes" CLIENT_SIDE_AUTH = "client_side_auth" -# constants for migration -DRY_RUN = "dry_run" - # constants for inclusion INCLUSION_STRATEGY = "inclusion_strategy" From 1cb1dfa456f6d4feb7cb6cf5f8e5136fd2588bdd Mon Sep 17 00:00:00 2001 From: Emory Penney Date: Thu, 2 Mar 2023 22:31:56 -0800 Subject: [PATCH 0197/1058] Add Obihai reboot button (#88849) * Obihai: Add reboot service * Switch to button * Remove button.py from coverage * Update homeassistant/components/obihai/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/button.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/obihai/button.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * PR Feedback * Cleanup some typehints * As a class attr --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 1 + homeassistant/components/obihai/button.py | 59 +++++++++++++++++++ .../components/obihai/connectivity.py | 2 +- homeassistant/components/obihai/const.py | 2 +- homeassistant/components/obihai/sensor.py | 3 +- 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/obihai/button.py diff --git a/.coveragerc b/.coveragerc index 5da330bb20a0..ce80bef0a930 100644 --- a/.coveragerc +++ b/.coveragerc @@ -809,6 +809,7 @@ omit = homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/oasa_telematics/sensor.py homeassistant/components/obihai/__init__.py + homeassistant/components/obihai/button.py homeassistant/components/obihai/connectivity.py homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py new file mode 100644 index 000000000000..0b84d40f4d2d --- /dev/null +++ b/homeassistant/components/obihai/button.py @@ -0,0 +1,59 @@ +"""Obihai button module.""" + +from __future__ import annotations + +from pyobihai import PyObihai + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform + +from .connectivity import ObihaiConnection +from .const import OBIHAI + +BUTTON_DESCRIPTION = ButtonEntityDescription( + key="reboot", + name=f"{OBIHAI} Reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up the Obihai sensor entries.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + host = entry.data[CONF_HOST] + requester = ObihaiConnection(host, username, password) + + await hass.async_add_executor_job(requester.update) + buttons = [ObihaiButton(requester.pyobihai, requester.serial)] + async_add_entities(buttons, update_before_add=True) + + +class ObihaiButton(ButtonEntity): + """Obihai Reboot button.""" + + entity_description = BUTTON_DESCRIPTION + + def __init__(self, pyobihai: PyObihai, serial: str) -> None: + """Initialize monitor sensor.""" + self._pyobihai = pyobihai + self._attr_unique_id = f"{serial}-reboot" + + def press(self) -> None: + """Press button.""" + + if not self._pyobihai.call_reboot(): + raise HomeAssistantError("Reboot failed!") diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py index 4a5c25b21018..93eeccd1bb7a 100644 --- a/homeassistant/components/obihai/connectivity.py +++ b/homeassistant/components/obihai/connectivity.py @@ -45,7 +45,7 @@ class ObihaiConnection: self.host = host self.username = username self.password = password - self.serial: list = [] + self.serial: str self.services: list = [] self.line_services: list = [] self.call_direction: list = [] diff --git a/homeassistant/components/obihai/const.py b/homeassistant/components/obihai/const.py index 90bcd7736f83..764534d4791c 100644 --- a/homeassistant/components/obihai/const.py +++ b/homeassistant/components/obihai/const.py @@ -12,4 +12,4 @@ OBIHAI = "Obihai" LOGGER = logging.getLogger(__package__) -PLATFORMS: Final = [Platform.SENSOR] +PLATFORMS: Final = [Platform.BUTTON, Platform.SENSOR] diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 7524fbc7d47c..61411b0ce271 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +from pyobihai import PyObihai import voluptuous as vol from homeassistant.components.sensor import ( @@ -89,7 +90,7 @@ async def async_setup_entry( class ObihaiServiceSensors(SensorEntity): """Get the status of each Obihai Lines.""" - def __init__(self, pyobihai, serial, service_name): + def __init__(self, pyobihai: PyObihai, serial: str, service_name: str) -> None: """Initialize monitor sensor.""" self._service_name = service_name self._state = None From 0f493d85c88094e7f79d004219d3b104f0b6e585 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 08:32:23 +0100 Subject: [PATCH 0198/1058] Adjust xiaomi_ble tests (#89078) Adjust xiaomi_ble test docstrings --- tests/components/xiaomi_ble/test_sensor.py | 37 ++++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 5dec404241d8..1d6344063b5f 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -152,8 +152,11 @@ async def test_xiaomi_battery_voltage(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_xiaomi_HHCCJCY01(hass: HomeAssistant) -> None: - """This device has multiple advertisements before all sensors are visible. Test that this works.""" +async def test_xiaomi_hhccjcy01(hass: HomeAssistant) -> None: + """Test HHCCJCY01 multiple advertisements. + + This device has multiple advertisements before all sensors are visible. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="C4:7C:8D:6A:3E:7A", @@ -230,8 +233,11 @@ async def test_xiaomi_HHCCJCY01(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_xiaomi_HHCCJCY01_not_connectable(hass: HomeAssistant) -> None: - """This device has multiple advertisements before all sensors are visible but not connectable.""" +async def test_xiaomi_hhccjcy01_not_connectable(hass: HomeAssistant) -> None: + """Test HHCCJCY01 when sensors are not connectable. + + This device has multiple advertisements before all sensors are visible but not connectable. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="C4:7C:8D:6A:3E:7A", @@ -311,10 +317,14 @@ async def test_xiaomi_HHCCJCY01_not_connectable(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_xiaomi_HHCCJCY01_only_some_sources_connectable( +async def test_xiaomi_hhccjcy01_only_some_sources_connectable( hass: HomeAssistant, ) -> None: - """This device has multiple advertisements before all sensors are visible and some sources are connectable.""" + """Test HHCCJCY01 partial sources. + + This device has multiple advertisements before all sensors are visible + and some sources are connectable. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="C4:7C:8D:6A:3E:7A", @@ -399,8 +409,12 @@ async def test_xiaomi_HHCCJCY01_only_some_sources_connectable( await hass.async_block_till_done() -async def test_xiaomi_CGDK2(hass: HomeAssistant) -> None: - """This device has encrypion so we need to retrieve its bindkey from the configentry.""" +async def test_xiaomi_cgdk2_bind_key(hass: HomeAssistant) -> None: + """Test CGDK2 bind key. + + This device has encryption so we need to retrieve its bind key + from the config entry. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="58:2D:34:12:20:89", @@ -436,8 +450,11 @@ async def test_xiaomi_CGDK2(hass: HomeAssistant) -> None: await hass.async_block_till_done() -async def test_hhcc_HHCCJCY10(hass: HomeAssistant) -> None: - """This device used a different UUID compared to the other Xiaomi sensors.""" +async def test_hhccjcy10_uuid(hass: HomeAssistant) -> None: + """Test HHCCJCY10 UUID. + + This device uses a different UUID compared to the other Xiaomi sensors. + """ entry = MockConfigEntry( domain=DOMAIN, unique_id="DC:23:4D:E5:5B:FC", From a5cf8210ae0080a388425bcdc7d21325d3b032c6 Mon Sep 17 00:00:00 2001 From: Thibaut Date: Fri, 3 Mar 2023 08:38:07 +0100 Subject: [PATCH 0199/1058] Move Cycle command from cover to button (#89043) Declare Cycle command as a button --- homeassistant/components/overkiz/button.py | 6 ++++++ .../components/overkiz/cover_entities/generic_cover.py | 2 -- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index 23f1558b2252..8388e2c3b2de 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from pyoverkiz.enums import OverkizCommand from pyoverkiz.types import StateType as OverkizStateType from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -65,6 +66,11 @@ BUTTON_DESCRIPTIONS: list[OverkizButtonDescription] = [ name="My position", icon="mdi:star", ), + OverkizButtonDescription( + key=OverkizCommand.CYCLE, + name="Toggle", + icon="mdi:sync", + ), ] SUPPORTED_COMMANDS = { diff --git a/homeassistant/components/overkiz/cover_entities/generic_cover.py b/homeassistant/components/overkiz/cover_entities/generic_cover.py index 1bc108b531d9..06f257d416b0 100644 --- a/homeassistant/components/overkiz/cover_entities/generic_cover.py +++ b/homeassistant/components/overkiz/cover_entities/generic_cover.py @@ -27,13 +27,11 @@ COMMANDS_STOP_TILT: list[OverkizCommand] = [ COMMANDS_OPEN: list[OverkizCommand] = [ OverkizCommand.OPEN, OverkizCommand.UP, - OverkizCommand.CYCLE, ] COMMANDS_OPEN_TILT: list[OverkizCommand] = [OverkizCommand.OPEN_SLATS] COMMANDS_CLOSE: list[OverkizCommand] = [ OverkizCommand.CLOSE, OverkizCommand.DOWN, - OverkizCommand.CYCLE, ] COMMANDS_CLOSE_TILT: list[OverkizCommand] = [OverkizCommand.CLOSE_SLATS] From 4a3c0cd0a874dab0f971435eb9d6345e35b2437a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 11:26:13 +0100 Subject: [PATCH 0200/1058] Adjust docstrings for ruff D404 (#89077) --- homeassistant/components/__init__.py | 2 +- homeassistant/components/actiontec/device_tracker.py | 2 +- homeassistant/components/alexa/errors.py | 2 +- homeassistant/components/arris_tg2492lg/device_tracker.py | 2 +- homeassistant/components/aruba/device_tracker.py | 2 +- homeassistant/components/bbox/device_tracker.py | 2 +- homeassistant/components/bt_home_hub_5/device_tracker.py | 2 +- homeassistant/components/bt_smarthub/device_tracker.py | 2 +- homeassistant/components/cisco_ios/device_tracker.py | 2 +- .../components/cisco_mobility_express/device_tracker.py | 2 +- homeassistant/components/ddwrt/device_tracker.py | 2 +- homeassistant/components/demo/geo_location.py | 2 +- homeassistant/components/forked_daapd/media_player.py | 2 +- homeassistant/components/fortios/device_tracker.py | 2 +- homeassistant/components/foscam/camera.py | 2 +- homeassistant/components/fritz/device_tracker.py | 2 +- homeassistant/components/fritzbox_callmonitor/base.py | 2 +- homeassistant/components/gdacs/geo_location.py | 2 +- homeassistant/components/gdacs/sensor.py | 2 +- homeassistant/components/geo_json_events/geo_location.py | 2 +- homeassistant/components/geonetnz_quakes/geo_location.py | 2 +- homeassistant/components/geonetnz_quakes/sensor.py | 2 +- homeassistant/components/geonetnz_volcano/sensor.py | 2 +- homeassistant/components/group/binary_sensor.py | 2 +- homeassistant/components/group/cover.py | 2 +- homeassistant/components/group/fan.py | 2 +- homeassistant/components/group/light.py | 2 +- homeassistant/components/group/lock.py | 2 +- homeassistant/components/group/media_player.py | 2 +- homeassistant/components/group/sensor.py | 2 +- homeassistant/components/group/switch.py | 2 +- homeassistant/components/hitron_coda/device_tracker.py | 2 +- homeassistant/components/ign_sismologia/geo_location.py | 2 +- homeassistant/components/intellifire/fan.py | 2 +- homeassistant/components/intellifire/light.py | 2 +- homeassistant/components/linksys_smart/device_tracker.py | 2 +- homeassistant/components/lirc/__init__.py | 2 +- homeassistant/components/luci/device_tracker.py | 2 +- homeassistant/components/mqtt/alarm_control_panel.py | 2 +- homeassistant/components/nmap_tracker/__init__.py | 2 +- .../components/nsw_rural_fire_service_feed/geo_location.py | 2 +- homeassistant/components/opnsense/device_tracker.py | 2 +- homeassistant/components/qld_bushfire/geo_location.py | 2 +- homeassistant/components/quantum_gateway/device_tracker.py | 2 +- homeassistant/components/rainmachine/binary_sensor.py | 2 +- homeassistant/components/rainmachine/sensor.py | 2 +- homeassistant/components/rainmachine/switch.py | 2 +- homeassistant/components/reolink/binary_sensor.py | 2 +- homeassistant/components/reolink/camera.py | 2 +- homeassistant/components/reolink/host.py | 2 +- homeassistant/components/reolink/number.py | 2 +- homeassistant/components/ring/binary_sensor.py | 2 +- homeassistant/components/ring/camera.py | 2 +- homeassistant/components/ring/light.py | 2 +- homeassistant/components/ring/sensor.py | 2 +- homeassistant/components/ring/siren.py | 2 +- homeassistant/components/ring/switch.py | 2 +- homeassistant/components/sky_hub/device_tracker.py | 2 +- homeassistant/components/stookalert/binary_sensor.py | 2 +- homeassistant/components/stookwijzer/sensor.py | 2 +- homeassistant/components/swisscom/device_tracker.py | 2 +- homeassistant/components/synology_srm/device_tracker.py | 2 +- homeassistant/components/tado/device_tracker.py | 2 +- homeassistant/components/thomson/device_tracker.py | 2 +- homeassistant/components/tomato/device_tracker.py | 2 +- homeassistant/components/travisci/sensor.py | 2 +- homeassistant/components/ubus/device_tracker.py | 2 +- homeassistant/components/unifi_direct/device_tracker.py | 2 +- homeassistant/components/unifiprotect/binary_sensor.py | 2 +- homeassistant/components/unifiprotect/light.py | 2 +- homeassistant/components/unifiprotect/number.py | 2 +- homeassistant/components/unifiprotect/select.py | 2 +- homeassistant/components/unifiprotect/sensor.py | 2 +- homeassistant/components/unifiprotect/switch.py | 2 +- homeassistant/components/upc_connect/device_tracker.py | 2 +- homeassistant/components/usgs_earthquakes_feed/geo_location.py | 2 +- homeassistant/components/xiaomi/camera.py | 2 +- homeassistant/components/xiaomi/device_tracker.py | 2 +- homeassistant/components/xiaomi_miio/device_tracker.py | 2 +- 79 files changed, 79 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index d0e631fb04c5..690b38b48717 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -1,4 +1,4 @@ -"""This package contains components that can be plugged into Home Assistant. +"""Contains components that can be plugged into Home Assistant. Component design guidelines: - Each component defines a constant DOMAIN that is equal to its filename. diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 9c18e2ba907c..5397fed5e1d4 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -40,7 +40,7 @@ def get_scanner( class ActiontecDeviceScanner(DeviceScanner): - """This class queries an actiontec router for connected devices.""" + """Class which queries an actiontec router for connected devices.""" def __init__(self, config: ConfigType) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 5f0de6f74670..7f4b41b9ec74 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -9,7 +9,7 @@ from .const import API_TEMP_UNITS class UnsupportedProperty(HomeAssistantError): - """This entity does not support the requested Smart Home API property.""" + """Does not support the requested Smart Home API property.""" class NoTokenAvailable(HomeAssistantError): diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index b456aa3f7039..48b8d9f13c47 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -33,7 +33,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArrisDeviceScanner: class ArrisDeviceScanner(DeviceScanner): - """This class queries a Arris TG2492LG router for connected devices.""" + """Class which queries a Arris TG2492LG router for connected devices.""" def __init__(self, connect_box: ConnectBox) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index d0794553b420..7b8c547fd536 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -42,7 +42,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArubaDeviceScanner | class ArubaDeviceScanner(DeviceScanner): - """This class queries a Aruba Access Point for connected devices.""" + """Class which queries a Aruba Access Point for connected devices.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index a9b0312673b5..9c83aaa1734a 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -42,7 +42,7 @@ Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) class BboxDeviceScanner(DeviceScanner): - """This class scans for devices connected to the bbox.""" + """Scanner for devices connected to the bbox.""" def __init__(self, config): """Get host from config.""" diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index 4d89c851245b..0ffa3bc699bd 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -35,7 +35,7 @@ def get_scanner( class BTHomeHub5DeviceScanner(DeviceScanner): - """This class queries a BT Home Hub 5.""" + """Class which queries a BT Home Hub 5.""" def __init__(self, config): """Initialise the scanner.""" diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 48475bbeac94..65aa1bd6a612 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -54,7 +54,7 @@ _Device = namedtuple("_Device", ["ip_address", "mac", "host", "status", "name"]) class BTSmartHubScanner(DeviceScanner): - """This class queries a BT Smart Hub.""" + """Class which queries a BT Smart Hub.""" def __init__(self, smarthub_client): """Initialise the scanner.""" diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 508b2b2d8b35..1424d41006dc 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -39,7 +39,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> CiscoDeviceScanner | class CiscoDeviceScanner(DeviceScanner): - """This class queries a wireless router running Cisco IOS firmware.""" + """Class which queries a wireless router running Cisco IOS firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 9ce98ec4fe8c..a5ca469d1016 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -56,7 +56,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> CiscoMEDeviceScanner class CiscoMEDeviceScanner(DeviceScanner): - """This class scans for devices associated to a Cisco ME controller.""" + """Scanner for devices associated to a Cisco ME controller.""" def __init__(self, controller): """Initialize the scanner.""" diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index ba34ec48e0f1..7874786adbab 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -55,7 +55,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> DdWrtDeviceScanner | class DdWrtDeviceScanner(DeviceScanner): - """This class queries a wireless router running DD-WRT firmware.""" + """Class which queries a wireless router running DD-WRT firmware.""" def __init__(self, config): """Initialize the DD-WRT scanner.""" diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py index cc29205b7201..2af7437e0f68 100644 --- a/homeassistant/components/demo/geo_location.py +++ b/homeassistant/components/demo/geo_location.py @@ -110,7 +110,7 @@ class DemoManager: class DemoGeolocationEvent(GeolocationEvent): - """This represents a demo geolocation event.""" + """Represents a demo geolocation event.""" _attr_should_poll = False diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index d42c72b65d1c..ca7e0cce27cd 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -1,4 +1,4 @@ -"""This library brings support for forked_daapd to Home Assistant.""" +"""Support forked_daapd media player.""" from __future__ import annotations import asyncio diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 65f63829c05e..95a418ae40fa 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -67,7 +67,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> FortiOSDeviceScanner class FortiOSDeviceScanner(DeviceScanner): - """This class queries a FortiOS unit for connected devices.""" + """Class which queries a FortiOS unit for connected devices.""" def __init__(self, fgt) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index fe11b056880b..ae28fd8d111a 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,4 +1,4 @@ -"""This component provides basic support for Foscam IP cameras.""" +"""Component providing basic support for Foscam IP cameras.""" from __future__ import annotations import asyncio diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 212710a638cd..e32ee1527969 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -68,7 +68,7 @@ def _async_add_entities( class FritzBoxTracker(FritzDeviceBase, ScannerEntity): - """This class queries a FRITZ!Box device.""" + """Class which queries a FRITZ!Box device.""" def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None: """Initialize a FRITZ!Box device.""" diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py index 386e60ba1990..df19bca7b13c 100644 --- a/homeassistant/components/fritzbox_callmonitor/base.py +++ b/homeassistant/components/fritzbox_callmonitor/base.py @@ -19,7 +19,7 @@ MIN_TIME_PHONEBOOK_UPDATE = timedelta(hours=6) class FritzBoxPhonebook: - """This connects to a FritzBox router and downloads its phone book.""" + """Connects to a FritzBox router and downloads its phone book.""" fph: FritzPhonebook phonebook_dict: dict[str, list[str]] diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 06ab1aa08378..1d3dabc464ce 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -77,7 +77,7 @@ async def async_setup_entry( class GdacsEvent(GeolocationEvent): - """This represents an external event with GDACS feed data.""" + """Represents an external event with GDACS feed data.""" _attr_should_poll = False _attr_source = SOURCE diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 531eb05dcf9e..6563e26368ab 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -39,7 +39,7 @@ async def async_setup_entry( class GdacsSensor(SensorEntity): - """This is a status sensor for the GDACS integration.""" + """Status sensor for the GDACS integration.""" _attr_should_poll = False diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 166da1184c67..74951bc3a97f 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -142,7 +142,7 @@ class GeoJsonFeedEntityManager: class GeoJsonLocationEvent(GeolocationEvent): - """This represents an external event with GeoJSON data.""" + """Represents an external event with GeoJSON data.""" _attr_should_poll = False _attr_source = SOURCE diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index 411a0375461c..6fa84f590f1f 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -65,7 +65,7 @@ async def async_setup_entry( class GeonetnzQuakesEvent(GeolocationEvent): - """This represents an external event with GeoNet NZ Quakes feed data.""" + """Represents an external event with GeoNet NZ Quakes feed data.""" _attr_icon = "mdi:pulse" _attr_should_poll = False diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 9183aead1690..8fb2ff8535b4 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -40,7 +40,7 @@ async def async_setup_entry( class GeonetnzQuakesSensor(SensorEntity): - """This is a status sensor for the GeoNet NZ Quakes integration.""" + """Status sensor for the GeoNet NZ Quakes integration.""" _attr_should_poll = False diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 25e02f44308c..33a879eeb255 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -54,7 +54,7 @@ async def async_setup_entry( class GeonetnzVolcanoSensor(SensorEntity): - """This represents an external event with GeoNet NZ Volcano feed data.""" + """Represents an external event with GeoNet NZ Volcano feed data.""" _attr_should_poll = False diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 815e3b76f0b9..112b111bdca5 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -1,4 +1,4 @@ -"""This platform allows several binary sensor to be grouped into one binary sensor.""" +"""Platform allowing several binary sensor to be grouped into one binary sensor.""" from __future__ import annotations import voluptuous as vol diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 2ecfbeaca42d..38928302eb11 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -1,4 +1,4 @@ -"""This platform allows several cover to be grouped into one cover.""" +"""Platform allowing several cover to be grouped into one cover.""" from __future__ import annotations from typing import Any diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 682890dddd60..0c4c59d24545 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -1,4 +1,4 @@ -"""This platform allows several fans to be grouped into one fan.""" +"""Platform allowing several fans to be grouped into one fan.""" from __future__ import annotations from functools import reduce diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 6315e79d61a9..33d240a9a4d1 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -1,4 +1,4 @@ -"""This platform allows several lights to be grouped into one light.""" +"""Platform allowing several lights to be grouped into one light.""" from __future__ import annotations from collections import Counter diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 9c39e1455286..07d08c7851d5 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -1,4 +1,4 @@ -"""This platform allows several locks to be grouped into one lock.""" +"""Platform allowing several locks to be grouped into one lock.""" from __future__ import annotations import logging diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index a349a6280040..3766c64cae51 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,4 +1,4 @@ -"""This platform allows several media players to be grouped into one media player.""" +"""Platform allowing several media players to be grouped into one media player.""" from __future__ import annotations from contextlib import suppress diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 6c379832ced7..265e1640d06d 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,4 +1,4 @@ -"""This platform allows several sensors to be grouped into one sensor to provide numeric combinations.""" +"""Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" from __future__ import annotations from collections.abc import Callable diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 8b60e1f14025..4b6b959ba17a 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -1,4 +1,4 @@ -"""This platform allows several switches to be grouped into one switch.""" +"""Platform allowing several switches to be grouped into one switch.""" from __future__ import annotations import logging diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index c9ee93634b2f..df1189f9e761 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -45,7 +45,7 @@ Device = namedtuple("Device", ["mac", "name"]) class HitronCODADeviceScanner(DeviceScanner): - """This class scans for devices using the CODA's web interface.""" + """Scanner for devices using the CODA's web interface.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index e78dafae8ee4..794da41ea126 100644 --- a/homeassistant/components/ign_sismologia/geo_location.py +++ b/homeassistant/components/ign_sismologia/geo_location.py @@ -141,7 +141,7 @@ class IgnSismologiaFeedEntityManager: class IgnSismologiaLocationEvent(GeolocationEvent): - """This represents an external event with IGN Sismologia feed data.""" + """Represents an external event with IGN Sismologia feed data.""" _attr_icon = "mdi:pulse" _attr_should_poll = False diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index 0f4385693895..aa74480fef11 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -72,7 +72,7 @@ async def async_setup_entry( class IntellifireFan(IntellifireEntity, FanEntity): - """This is Fan entity for the fireplace.""" + """Fan entity for the fireplace.""" entity_description: IntellifireFanEntityDescription _attr_supported_features = FanEntityFeature.SET_SPEED diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index f1fd81ab452b..5e7d5735a6c2 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -49,7 +49,7 @@ INTELLIFIRE_LIGHTS: tuple[IntellifireLightEntityDescription, ...] = ( class IntellifireLight(IntellifireEntity, LightEntity): - """This is a Light entity for the fireplace.""" + """Light entity for the fireplace.""" entity_description: IntellifireLightEntityDescription _attr_color_mode = ColorMode.BRIGHTNESS diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py index 3b0aeffaa6d2..d0440c832c8a 100644 --- a/homeassistant/components/linksys_smart/device_tracker.py +++ b/homeassistant/components/linksys_smart/device_tracker.py @@ -35,7 +35,7 @@ def get_scanner( class LinksysSmartWifiDeviceScanner(DeviceScanner): - """This class queries a Linksys Access Point.""" + """Class which queries a Linksys Access Point.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index c5ebf874681f..cf76213a88e2 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -42,7 +42,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class LircInterface(threading.Thread): - """This interfaces with the lirc daemon to read IR commands. + """Interfaces with the lirc daemon to read IR commands. When using lirc in blocking mode, sometimes repeated commands get produced in the next read of a command so we use a thread here to just wait diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index d18ecf8bd4e5..f4ebe4376f34 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -46,7 +46,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> LuciDeviceScanner | class LuciDeviceScanner(DeviceScanner): - """This class scans for devices connected to an OpenWrt router.""" + """Scanner for devices connected to an OpenWrt router.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 865131132817..b685daaf6f18 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -1,4 +1,4 @@ -"""This platform enables the possibility to control a MQTT alarm.""" +"""Control a MQTT alarm.""" from __future__ import annotations import functools diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 827fb93a0121..0dafff996d06 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -132,7 +132,7 @@ def signal_device_update(mac_address) -> str: class NmapDeviceScanner: - """This class scans for devices using nmap.""" + """Scanner for devices using nmap.""" def __init__( self, hass: HomeAssistant, entry: ConfigEntry, devices: NmapTrackedDevices diff --git a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py index 3eb598ffd3e3..28e056e29fbc 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py +++ b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py @@ -177,7 +177,7 @@ class NswRuralFireServiceFeedEntityManager: class NswRuralFireServiceLocationEvent(GeolocationEvent): - """This represents an external event with NSW Rural Fire Service data.""" + """Represents an external event with NSW Rural Fire Service data.""" _attr_should_poll = False _attr_source = SOURCE diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py index b5c75f1cc21b..527856ed56e3 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -20,7 +20,7 @@ async def async_get_scanner( class OPNSenseDeviceScanner(DeviceScanner): - """This class queries a router running OPNsense.""" + """Class which queries a router running OPNsense.""" def __init__(self, client, interfaces): """Initialize the scanner.""" diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index fc9fd7276154..1adddc485599 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -149,7 +149,7 @@ class QldBushfireFeedEntityManager: class QldBushfireLocationEvent(GeolocationEvent): - """This represents an external event with Qld Bushfire feed data.""" + """Represents an external event with Qld Bushfire feed data.""" _attr_icon = "mdi:fire" _attr_should_poll = False diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index 076c1d2722bc..c8e23b684160 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -40,7 +40,7 @@ def get_scanner( class QuantumGatewayDeviceScanner(DeviceScanner): - """This class queries a Quantum Gateway.""" + """Class which queries a Quantum Gateway.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 5815c0ce1264..33650cfc2fef 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -1,4 +1,4 @@ -"""This platform provides binary sensors for key RainMachine data.""" +"""Binary sensors for key RainMachine data.""" from dataclasses import dataclass from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 3d56ff59fc5f..22943d73fcb4 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -1,4 +1,4 @@ -"""This platform provides support for sensor data from RainMachine.""" +"""Support for sensor data from RainMachine.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index ae445d82783d..60db5085951c 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -1,4 +1,4 @@ -"""This component provides support for RainMachine programs and zones.""" +"""Component providing support for RainMachine programs and zones.""" from __future__ import annotations import asyncio diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 541ad9ec9989..3c97087c89d2 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -1,4 +1,4 @@ -"""This component provides support for Reolink binary sensors.""" +"""Component providing support for Reolink binary sensors.""" from __future__ import annotations from collections.abc import Callable diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index d14906a57820..4a270d6f5a69 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,4 +1,4 @@ -"""This component provides support for Reolink IP cameras.""" +"""Component providing support for Reolink IP cameras.""" from __future__ import annotations import logging diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 73c0e70812cc..9994afe79a82 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -1,4 +1,4 @@ -"""This component encapsulates the NVR/camera API and subscription.""" +"""Module which encapsulates the NVR/camera API and subscription.""" from __future__ import annotations import asyncio diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e9b692fffe61..c1baf4b156f6 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -1,4 +1,4 @@ -"""This component provides support for Reolink number entities.""" +"""Component providing support for Reolink number entities.""" from __future__ import annotations from collections.abc import Callable diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 06872cc73387..d2c01bbd4f36 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -1,4 +1,4 @@ -"""This component provides HA sensor support for Ring Door Bell/Chimes.""" +"""Component providing HA sensor support for Ring Door Bell/Chimes.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index f5d70a86cb36..e99fabfab2f2 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -1,4 +1,4 @@ -"""This component provides support to the Ring Door Bell camera.""" +"""Component providing support to the Ring Door Bell camera.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index e6b29b94fbf8..143c333f6006 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -1,4 +1,4 @@ -"""This component provides HA switch support for Ring Door Bell/Chimes.""" +"""Component providing HA switch support for Ring Door Bell/Chimes.""" from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 027eccb1c3d7..3d198ce7573c 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -1,4 +1,4 @@ -"""This component provides HA sensor support for Ring Door Bell/Chimes.""" +"""Component providing HA sensor support for Ring Door Bell/Chimes.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index b83d3e7b2aea..626444a9dcf5 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -1,4 +1,4 @@ -"""This component provides HA Siren support for Ring Chimes.""" +"""Component providing HA Siren support for Ring Chimes.""" import logging from typing import Any diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 0fa6e3b11142..9a3c80114e9e 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -1,4 +1,4 @@ -"""This component provides HA switch support for Ring Door Bell/Chimes.""" +"""Component providing HA switch support for Ring Door Bell/Chimes.""" from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index 65d806a9bcad..8741b2ed5609 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -39,7 +39,7 @@ async def async_get_scanner( class SkyHubDeviceScanner(DeviceScanner): - """This class queries a Sky Hub router.""" + """Class which queries a Sky Hub router.""" def __init__(self, hub): """Initialise the scanner.""" diff --git a/homeassistant/components/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py index 70a25c2bfdf6..d3920d3f0e42 100644 --- a/homeassistant/components/stookalert/binary_sensor.py +++ b/homeassistant/components/stookalert/binary_sensor.py @@ -1,4 +1,4 @@ -"""This integration provides support for Stookalert Binary Sensor.""" +"""Support for Stookalert Binary Sensor.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index 9eb70fda7ee5..cd84bec11b22 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -1,4 +1,4 @@ -"""This integration provides support for Stookwijzer Sensor.""" +"""Support for Stookwijzer Sensor.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 29da03b262e7..900117a54b75 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -36,7 +36,7 @@ def get_scanner( class SwisscomDeviceScanner(DeviceScanner): - """This class queries a router running Swisscom Internet-Box firmware.""" + """Class which queries a router running Swisscom Internet-Box firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 15c61ff0a3cf..e67f7ecf34eb 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -80,7 +80,7 @@ def get_scanner( class SynologySrmDeviceScanner(DeviceScanner): - """This class scans for devices connected to a Synology SRM router.""" + """Scanner for devices connected to a Synology SRM router.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 72eb9c8e289b..4d50bc35c3b7 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -48,7 +48,7 @@ Device = namedtuple("Device", ["mac", "name"]) class TadoDeviceScanner(DeviceScanner): - """This class gets geofenced devices from Tado.""" + """Scanner for geofenced devices from Tado.""" def __init__(self, hass, config): """Initialize the scanner.""" diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index 4af21ec8e160..e42ee4478e01 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -46,7 +46,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> ThomsonDeviceScanner class ThomsonDeviceScanner(DeviceScanner): - """This class queries a router running THOMSON firmware.""" + """Class which queries a router running THOMSON firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index e10bc3b81d6b..da64157dad86 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -49,7 +49,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> TomatoDeviceScanner: class TomatoDeviceScanner(DeviceScanner): - """This class queries a wireless router running Tomato firmware.""" + """Class which queries a wireless router running Tomato firmware.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index ec62da376536..6a30c1b62ba3 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -1,4 +1,4 @@ -"""This component provides HA sensor support for Travis CI framework.""" +"""Component providing HA sensor support for Travis CI framework.""" from __future__ import annotations from datetime import timedelta diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 20b0ad6593d8..48d5b4bd6f6b 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -68,7 +68,7 @@ def _refresh_on_access_denied(func): class UbusDeviceScanner(DeviceScanner): - """This class queries a wireless router running OpenWrt firmware. + """Class which queries a wireless router running OpenWrt firmware. Adapted from Tomato scanner. """ diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index 42f83dad5d51..13ebd0e33e57 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -43,7 +43,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> UnifiDeviceScanner | class UnifiDeviceScanner(DeviceScanner): - """This class queries Unifi wireless access point.""" + """Class which queries Unifi wireless access point.""" def __init__(self, config): """Initialize the scanner.""" diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index d61a47e8c7ab..7aa7c6d5cf14 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -1,4 +1,4 @@ -"""This component provides binary sensors for UniFi Protect.""" +"""Component providing binary sensors for UniFi Protect.""" from __future__ import annotations from copy import copy diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index feb0be66ecd0..500b4b4703ea 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -1,4 +1,4 @@ -"""This component provides Lights for UniFi Protect.""" +"""Component providing Lights for UniFi Protect.""" from __future__ import annotations import logging diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index ba6ae819dd2b..247e401b2ca1 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -1,4 +1,4 @@ -"""This component provides number entities for UniFi Protect.""" +"""Component providing number entities for UniFi Protect.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7fe43bee9bb4..36870bf9c37e 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -1,4 +1,4 @@ -"""This component provides select entities for UniFi Protect.""" +"""Component providing select entities for UniFi Protect.""" from __future__ import annotations from collections.abc import Callable diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 5b17ed0020cf..783955b34012 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -1,4 +1,4 @@ -"""This component provides sensors for UniFi Protect.""" +"""Component providing sensors for UniFi Protect.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 295b70142615..ea2d8256cbe0 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -1,4 +1,4 @@ -"""This component provides Switches for UniFi Protect.""" +"""Component providing Switches for UniFi Protect.""" from __future__ import annotations from dataclasses import dataclass diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index 3025ea746d0a..2b5ee2915ef3 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -57,7 +57,7 @@ async def async_get_scanner( class UPCDeviceScanner(DeviceScanner): - """This class queries a router running UPC ConnectBox firmware.""" + """Class which queries a router running UPC ConnectBox firmware.""" def __init__(self, connect_box: ConnectBox) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index 28927baf9268..99aecfc406b5 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -195,7 +195,7 @@ class UsgsEarthquakesFeedEntityManager: class UsgsEarthquakesEvent(GeolocationEvent): - """This represents an external event with USGS Earthquake data.""" + """Represents an external event with USGS Earthquake data.""" _attr_icon = "mdi:pulse" _attr_should_poll = False diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index 8b7abcd2fe63..e9d686a63653 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -1,4 +1,4 @@ -"""This component provides support for Xiaomi Cameras.""" +"""Component providing support for Xiaomi Cameras.""" from __future__ import annotations from ftplib import FTP, error_perm diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index b8cf5f005c46..f277060304aa 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -36,7 +36,7 @@ def get_scanner(hass: HomeAssistant, config: ConfigType) -> XiaomiDeviceScanner class XiaomiDeviceScanner(DeviceScanner): - """This class queries a Xiaomi Mi router. + """Class which queries a Xiaomi Mi router. Adapted from Luci scanner. """ diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index e4bebdd0e629..977dc29ac423 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -53,7 +53,7 @@ def get_scanner( class XiaomiMiioDeviceScanner(DeviceScanner): - """This class queries a Xiaomi Mi WiFi Repeater.""" + """Class which queries a Xiaomi Mi WiFi Repeater.""" def __init__(self, device): """Initialize the scanner.""" From 699cc6c092880950aa8bcdcfb80fb25bafcf4768 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 11:34:20 +0100 Subject: [PATCH 0201/1058] Adjust docstring on hassfest generated files (#89080) --- homeassistant/generated/application_credentials.py | 2 +- homeassistant/generated/bluetooth.py | 2 +- homeassistant/generated/config_flows.py | 2 +- homeassistant/generated/countries.py | 2 +- homeassistant/generated/currencies.py | 2 +- homeassistant/generated/dhcp.py | 2 +- homeassistant/generated/languages.py | 2 +- homeassistant/generated/mqtt.py | 2 +- homeassistant/generated/ssdp.py | 2 +- homeassistant/generated/usb.py | 2 +- homeassistant/generated/zeroconf.py | 2 +- script/hassfest/serializer.py | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index b15642d46e1c..59e76a9c8aeb 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 86da242be80a..fc2950843156 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3621c1d48d14..8e13dd971e5c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/countries.py b/homeassistant/generated/countries.py index 76482a524deb..452e65afb02b 100644 --- a/homeassistant/generated/countries.py +++ b/homeassistant/generated/countries.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.countries diff --git a/homeassistant/generated/currencies.py b/homeassistant/generated/currencies.py index 546bc125a010..3cf2b9a1ab4b 100644 --- a/homeassistant/generated/currencies.py +++ b/homeassistant/generated/currencies.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.currencies """ diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8956085a5abf..5be29e022f13 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/languages.py b/homeassistant/generated/languages.py index 879d4a4cd41e..b4aebb0f1a4e 100644 --- a/homeassistant/generated/languages.py +++ b/homeassistant/generated/languages.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.languages [frontend_tag] """ diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index 5d64546b91bb..69abf7c64fe5 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index ca6a22e85d66..e5e83d5eae9f 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 2d0dced89658..f58936caf8de 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e00a0710c339..2f3dbaefb173 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -1,4 +1,4 @@ -"""This file is automatically generated. +"""Automatically generated file. To update, run python3 -m script.hassfest """ diff --git a/script/hassfest/serializer.py b/script/hassfest/serializer.py index 41f6a554aff9..0dd3d35bb154 100644 --- a/script/hassfest/serializer.py +++ b/script/hassfest/serializer.py @@ -65,7 +65,7 @@ def format_python( ) -> str: """Format Python code with Black. Optionally prepend a generator comment.""" if generator: - content = f"""\"\"\"This file is automatically generated. + content = f"""\"\"\"Automatically generated file. To update, run python3 -m {generator} \"\"\" From 9e6f869438f448c122b5dfcaec30278eae7a0f1c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 11:57:41 +0100 Subject: [PATCH 0202/1058] Set Protocol inheritance on EnergyPlatform (#89079) --- homeassistant/components/energy/types.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energy/types.py b/homeassistant/components/energy/types.py index 9a599cb9a59a..819ed6ac5a8e 100644 --- a/homeassistant/components/energy/types.py +++ b/homeassistant/components/energy/types.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import TypedDict +from typing import Protocol, TypedDict from homeassistant.core import HomeAssistant @@ -18,8 +18,8 @@ GetSolarForecastType = Callable[ ] -class EnergyPlatform: - """This class represents the methods we expect on the energy platforms.""" +class EnergyPlatform(Protocol): + """Represents the methods we expect on the energy platforms.""" @staticmethod async def async_get_solar_forecast( From 0598417894553ac008b30a57e0420e7d82e573d1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Mar 2023 12:38:40 +0100 Subject: [PATCH 0203/1058] Enable ruff D404 (#89093) --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c0c23166880b..850995273bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -268,7 +268,6 @@ ignore = [ "D202", # No blank lines allowed after function docstring "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line - "D404", # First word of the docstring should not be This "D406", # Section name should end with a newline "D407", # Section name underlining "E501", # line too long From 9736fe1f999915f7746b544a41df13cd77a1af69 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Fri, 3 Mar 2023 13:44:57 +0100 Subject: [PATCH 0204/1058] Add missing Edl21 sensor 1-0:0.0.0*255 (#87389) * Added missing sensor. * OwnerShip entity is disabled by default. --- homeassistant/components/edl21/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index e34c9c823f61..355d448e3016 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -53,6 +53,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # A=1: Electricity # C=0: General purpose objects # D=0: Free ID-numbers for utilities + # E=0 Ownership ID + SensorEntityDescription( + key="1-0:0.0.0*255", + name="Ownership ID", + icon="mdi:flash", + entity_registry_enabled_default=False, + ), + # E=9: Electrity ID SensorEntityDescription( key="1-0:0.0.9*255", name="Electricity ID", icon="mdi:flash" ), From 415190683fa4d5fa0a515b48970652095f925998 Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 3 Mar 2023 09:04:27 -0500 Subject: [PATCH 0205/1058] Updating Intellifire Naming scheme (#88666) --- .../components/intellifire/binary_sensor.py | 26 +++++++++---------- .../components/intellifire/coordinator.py | 2 +- .../components/intellifire/entity.py | 3 ++- homeassistant/components/intellifire/fan.py | 1 - homeassistant/components/intellifire/light.py | 4 +-- .../components/intellifire/number.py | 4 +-- .../components/intellifire/sensor.py | 6 ++--- .../components/intellifire/switch.py | 2 +- 8 files changed, 24 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index d189a09a739a..5a7407836f25 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -44,25 +44,25 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="timer_on", - name="Timer On", + name="Timer on", icon="mdi:camera-timer", value_fn=lambda data: data.timer_on, ), IntellifireBinarySensorEntityDescription( key="pilot_light_on", - name="Pilot Light On", + name="Pilot light on", icon="mdi:fire-alert", value_fn=lambda data: data.pilot_on, ), IntellifireBinarySensorEntityDescription( key="thermostat_on", - name="Thermostat On", + name="Thermostat on", icon="mdi:home-thermometer-outline", value_fn=lambda data: data.thermostat_on, ), IntellifireBinarySensorEntityDescription( key="error_pilot_flame", - name="Pilot Flame Error", + name="Pilot flame error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_pilot_flame, device_class=BinarySensorDeviceClass.PROBLEM, @@ -76,7 +76,7 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_fan_delay", - name="Fan Delay Error", + name="Fan delay error", icon="mdi:fan-alert", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_fan_delay, @@ -84,21 +84,21 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_maintenance", - name="Maintenance Error", + name="Maintenance error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_maintenance, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_disabled", - name="Disabled Error", + name="Disabled error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_disabled, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_fan", - name="Fan Error", + name="Fan error", icon="mdi:fan-alert", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_fan, @@ -106,35 +106,35 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_lights", - name="Lights Error", + name="Lights error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_lights, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_accessory", - name="Accessory Error", + name="Accessory error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_accessory, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_soft_lock_out", - name="Soft Lock Out Error", + name="Soft lock out error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_soft_lock_out, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_ecm_offline", - name="ECM Offline Error", + name="ECM offline error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_ecm_offline, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_offline", - name="Offline Error", + name="Offline error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_offline, device_class=BinarySensorDeviceClass.PROBLEM, diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index b6753adef763..5003ed91437b 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -67,7 +67,7 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData return DeviceInfo( manufacturer="Hearth and Home", model="IFT-WFM", - name="IntelliFire Fireplace", + name="IntelliFire", identifiers={("IntelliFire", f"{self.read_api.data.serial}]")}, sw_version=self.read_api.data.fw_ver_str, configuration_url=f"http://{self._api.fireplace_ip}/poll", diff --git a/homeassistant/components/intellifire/entity.py b/homeassistant/components/intellifire/entity.py index 3c427250f193..1e406aeb1198 100644 --- a/homeassistant/components/intellifire/entity.py +++ b/homeassistant/components/intellifire/entity.py @@ -21,7 +21,8 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): super().__init__(coordinator=coordinator) self.entity_description = description # Set the Display name the User will see - self._attr_name = f"Fireplace {description.name}" + self._attr_name = description.name self._attr_unique_id = f"{description.key}_{coordinator.read_api.data.serial}" + self._attr_has_entity_name = True # Configure the Device Info self._attr_device_info = self.coordinator.device_info diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index aa74480fef11..debc8237fc83 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -46,7 +46,6 @@ INTELLIFIRE_FANS: tuple[IntellifireFanEntityDescription, ...] = ( IntellifireFanEntityDescription( key="fan", name="Fan", - has_entity_name=True, set_fn=lambda control_api, speed: control_api.set_fan_speed(speed=speed), value_fn=lambda data: data.fanspeed, speed_range=(1, 4), diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index 5e7d5735a6c2..383d61b8d410 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -41,7 +41,6 @@ INTELLIFIRE_LIGHTS: tuple[IntellifireLightEntityDescription, ...] = ( IntellifireLightEntityDescription( key="lights", name="Lights", - has_entity_name=True, set_fn=lambda control_api, level: control_api.set_lights(level=level), value_fn=lambda data: data.light_level, ), @@ -95,3 +94,4 @@ async def async_setup_entry( for description in INTELLIFIRE_LIGHTS ) return + LOGGER.debug("Disabling Lights - IntelliFire device does not appear to have one") diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index efa567d55cbd..1b0913f3f3a9 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -26,8 +26,8 @@ async def async_setup_entry( coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] description = NumberEntityDescription( - key="flame_control", - name="Flame control", + key="flame_height", + name="Flame height", icon="mdi:arrow-expand-vertical", ) diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index 12f66a3f2784..e888ea1bbcf5 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -57,7 +57,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( IntellifireSensorEntityDescription( key="flame_height", icon="mdi:fire-circle", - name="Flame Height", + name="Flame height", state_class=SensorStateClass.MEASUREMENT, # UI uses 1-5 for flame height, backing lib uses 0-4 value_fn=lambda data: (data.flameheight + 1), @@ -72,7 +72,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( ), IntellifireSensorEntityDescription( key="target_temp", - name="Target Temperature", + name="Target temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -116,7 +116,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( ), IntellifireSensorEntityDescription( key="ecm_latency", - name="ECM Latency", + name="ECM latency", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.ecm_latency, entity_registry_enabled_default=False, diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index ef0363696c46..98abaa38849d 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -44,7 +44,7 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( ), IntellifireSwitchEntityDescription( key="pilot", - name="Pilot Light", + name="Pilot light", icon="mdi:fire-alert", on_fn=lambda control_api: control_api.pilot_on(), off_fn=lambda control_api: control_api.pilot_off(), From 3a34f818e8add8527ec66c85a4066162d2a715d9 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Fri, 3 Mar 2023 15:23:38 +0100 Subject: [PATCH 0206/1058] Refactor Livisi Switch and Climate to inherit from a common base class (#89085) * Refactor Livisi entities to inherit from a common base class * Add livisi_entity to .coveragerc * Device location can be None * Add use_room_as_device_name argument to constructor of LivisiEntity When initializing, set entity name attribute only if device name differs (i.e. use_room_as_device_name=True). * re-add comment for special handling of climate device names * Add explicit type to constructur argument * Make use_room_as_device_name a keyword only arg * rename livisi_entity.py to entity.py * change livisi_entity.py to entity.py in coveragerc * Code quality improvements as suggested in PR * sort .coveragerc * fix isort issue * fix all isort issues --- .coveragerc | 1 + homeassistant/components/livisi/climate.py | 94 +++++----------------- homeassistant/components/livisi/entity.py | 81 +++++++++++++++++++ homeassistant/components/livisi/switch.py | 83 +++---------------- 4 files changed, 112 insertions(+), 147 deletions(-) create mode 100644 homeassistant/components/livisi/entity.py diff --git a/.coveragerc b/.coveragerc index ce80bef0a930..48fb4044572a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -643,6 +643,7 @@ omit = homeassistant/components/livisi/__init__.py homeassistant/components/livisi/climate.py homeassistant/components/livisi/coordinator.py + homeassistant/components/livisi/entity.py homeassistant/components/livisi/switch.py homeassistant/components/llamalab_automate/notify.py homeassistant/components/logi_circle/__init__.py diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index f99ad8dbe72e..a6680a19af3a 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -1,11 +1,8 @@ """Code to handle a Livisi Virtual Climate Control.""" from __future__ import annotations -from collections.abc import Mapping from typing import Any -from aiolivisi.const import CAPABILITY_MAP - from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -16,13 +13,10 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DOMAIN, - LIVISI_REACHABILITY_CHANGE, LIVISI_STATE_CHANGE, LOGGER, MAX_TEMPERATURE, @@ -30,6 +24,7 @@ from .const import ( VRCC_DEVICE_TYPE, ) from .coordinator import LivisiDataUpdateCoordinator +from .entity import LivisiEntity async def async_setup_entry( @@ -50,8 +45,8 @@ async def async_setup_entry( device["type"] == VRCC_DEVICE_TYPE and device["id"] not in coordinator.devices ): - livisi_climate: ClimateEntity = create_entity( - config_entry, device, coordinator + livisi_climate: ClimateEntity = LivisiClimate( + config_entry, coordinator, device ) LOGGER.debug("Include device type: %s", device.get("type")) coordinator.devices.add(device["id"]) @@ -63,32 +58,7 @@ async def async_setup_entry( ) -def create_entity( - config_entry: ConfigEntry, - device: dict[str, Any], - coordinator: LivisiDataUpdateCoordinator, -) -> ClimateEntity: - """Create Climate Entity.""" - capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] - config_details: Mapping[str, Any] = device["config"] - room_id: str = device["location"] - room_name: str = coordinator.rooms[room_id] - livisi_climate = LivisiClimate( - config_entry, - coordinator, - unique_id=device["id"], - manufacturer=device["manufacturer"], - device_type=device["type"], - target_temperature_capability=capabilities["RoomSetpoint"], - temperature_capability=capabilities["RoomTemperature"], - humidity_capability=capabilities["RoomHumidity"], - room=room_name, - name=config_details["name"], - ) - return livisi_climate - - -class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntity): +class LivisiClimate(LivisiEntity, ClimateEntity): """Represents the Livisi Climate.""" _attr_hvac_modes = [HVACMode.HEAT] @@ -97,39 +67,21 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_target_temperature_high = MAX_TEMPERATURE _attr_target_temperature_low = MIN_TEMPERATURE - _attr_has_entity_name = True def __init__( self, config_entry: ConfigEntry, coordinator: LivisiDataUpdateCoordinator, - unique_id: str, - manufacturer: str, - device_type: str, - target_temperature_capability: str, - temperature_capability: str, - humidity_capability: str, - room: str, - name: str, + device: dict[str, Any], ) -> None: """Initialize the Livisi Climate.""" - self.config_entry = config_entry - self._attr_unique_id = unique_id - self._target_temperature_capability = target_temperature_capability - self._temperature_capability = temperature_capability - self._humidity_capability = humidity_capability - self.aio_livisi = coordinator.aiolivisi - self._attr_available = False - self._attr_name = name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer=manufacturer, - model=device_type, - name=room, - suggested_area=room, - via_device=(DOMAIN, config_entry.entry_id), + super().__init__( + config_entry, coordinator, device, use_room_as_device_name=True ) - super().__init__(coordinator) + + self._target_temperature_capability = self.capabilities["RoomSetpoint"] + self._temperature_capability = self.capabilities["RoomTemperature"] + self._humidity_capability = self.capabilities["RoomHumidity"] async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -142,11 +94,11 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit self._attr_available = False raise HomeAssistantError(f"Failed to turn off {self._attr_name}") - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Do nothing as LIVISI devices do not support changing the hvac mode.""" - async def async_added_to_hass(self) -> None: """Register callbacks.""" + + await super().async_added_to_hass() + target_temperature = await self.coordinator.async_get_vrcc_target_temperature( self._target_temperature_capability ) @@ -184,13 +136,9 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit self.update_humidity, ) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", - self.update_reachability, - ) - ) + + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Do nothing as LIVISI devices do not support changing the hvac mode.""" @callback def update_target_temperature(self, target_temperature: float) -> None: @@ -206,12 +154,6 @@ class LivisiClimate(CoordinatorEntity[LivisiDataUpdateCoordinator], ClimateEntit @callback def update_humidity(self, humidity: int) -> None: - """Update the humidity temperature of the climate device.""" + """Update the humidity of the climate device.""" self._attr_current_humidity = humidity self.async_write_ha_state() - - @callback - def update_reachability(self, is_reachable: bool) -> None: - """Update the reachability of the climate device.""" - self._attr_available = is_reachable - self.async_write_ha_state() diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py new file mode 100644 index 000000000000..613f55d1b7eb --- /dev/null +++ b/homeassistant/components/livisi/entity.py @@ -0,0 +1,81 @@ +"""Code to handle a Livisi switches.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiolivisi.const import CAPABILITY_MAP + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LIVISI_REACHABILITY_CHANGE +from .coordinator import LivisiDataUpdateCoordinator + + +class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator], Entity): + """Represents a base livisi entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + *, + use_room_as_device_name: bool = False, + ) -> None: + """Initialize the common properties of a Livisi device.""" + self.aio_livisi = coordinator.aiolivisi + self.capabilities: Mapping[str, Any] = device[CAPABILITY_MAP] + + name = device["config"]["name"] + unique_id = device["id"] + + room_id: str | None = device.get("location") + room_name: str | None = None + if room_id is not None: + room_name = coordinator.rooms.get(room_id) + + self._attr_available = False + self._attr_unique_id = unique_id + + device_name = name + + # For livisi climate entities, the device should have the room name from + # the livisi setup, as each livisi room gets exactly one VRCC device. The entity + # name will always be some localized value of "Climate", so the full element name + # in homeassistent will be in the form of "Bedroom Climate" + if use_room_as_device_name and room_name is not None: + self._attr_name = name + device_name = room_name + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=device["manufacturer"], + model=device["type"], + name=device_name, + suggested_area=room_name, + via_device=(DOMAIN, config_entry.entry_id), + ) + super().__init__(coordinator) + + async def async_added_to_hass(self) -> None: + """Register callback for reachability.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", + self.update_reachability, + ) + ) + + @callback + def update_reachability(self, is_reachable: bool) -> None: + """Update the reachability of the device.""" + self._attr_available = is_reachable + self.async_write_ha_state() diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index bcb9a2044119..f5201ab8faac 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -8,18 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DOMAIN, - LIVISI_REACHABILITY_CHANGE, - LIVISI_STATE_CHANGE, - LOGGER, - PSS_DEVICE_TYPE, -) +from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, PSS_DEVICE_TYPE from .coordinator import LivisiDataUpdateCoordinator +from .entity import LivisiEntity async def async_setup_entry( @@ -40,8 +33,8 @@ async def async_setup_entry( device["type"] == PSS_DEVICE_TYPE and device["id"] not in coordinator.devices ): - livisi_switch: SwitchEntity = create_entity( - config_entry, device, coordinator + livisi_switch: SwitchEntity = LivisiSwitch( + config_entry, coordinator, device ) LOGGER.debug("Include device type: %s", device["type"]) coordinator.devices.add(device["id"]) @@ -53,59 +46,18 @@ async def async_setup_entry( ) -def create_entity( - config_entry: ConfigEntry, - device: dict[str, Any], - coordinator: LivisiDataUpdateCoordinator, -) -> SwitchEntity: - """Create Switch Entity.""" - config_details: dict[str, Any] = device["config"] - capabilities: list = device["capabilities"] - room_id: str = device["location"] - room_name: str = coordinator.rooms[room_id] - livisi_switch = LivisiSwitch( - config_entry, - coordinator, - unique_id=device["id"], - manufacturer=device["manufacturer"], - device_type=device["type"], - name=config_details["name"], - capability_id=capabilities[0], - room=room_name, - ) - return livisi_switch - - -class LivisiSwitch(CoordinatorEntity[LivisiDataUpdateCoordinator], SwitchEntity): +class LivisiSwitch(LivisiEntity, SwitchEntity): """Represents the Livisi Switch.""" def __init__( self, config_entry: ConfigEntry, coordinator: LivisiDataUpdateCoordinator, - unique_id: str, - manufacturer: str, - device_type: str, - name: str, - capability_id: str, - room: str, + device: dict[str, Any], ) -> None: - """Initialize the Livisi Switch.""" - self.config_entry = config_entry - self._attr_unique_id = unique_id - self._attr_name = name - self._capability_id = capability_id - self.aio_livisi = coordinator.aiolivisi - self._attr_available = False - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - manufacturer=manufacturer, - model=device_type, - name=name, - suggested_area=room, - via_device=(DOMAIN, config_entry.entry_id), - ) - super().__init__(coordinator) + """Initialize the Livisi switch.""" + super().__init__(config_entry, coordinator, device) + self._capability_id = self.capabilities["SwitchActuator"] async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -127,6 +79,8 @@ class LivisiSwitch(CoordinatorEntity[LivisiDataUpdateCoordinator], SwitchEntity) async def async_added_to_hass(self) -> None: """Register callbacks.""" + await super().async_added_to_hass() + response = await self.coordinator.async_get_pss_state(self._capability_id) if response is None: self._attr_is_on = False @@ -140,22 +94,9 @@ class LivisiSwitch(CoordinatorEntity[LivisiDataUpdateCoordinator], SwitchEntity) self.update_states, ) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{self.unique_id}", - self.update_reachability, - ) - ) @callback def update_states(self, state: bool) -> None: - """Update the states of the switch device.""" + """Update the state of the switch device.""" self._attr_is_on = state self.async_write_ha_state() - - @callback - def update_reachability(self, is_reachable: bool) -> None: - """Update the reachability of the switch device.""" - self._attr_available = is_reachable - self.async_write_ha_state() From 1d9e8c873fb31d27f392a8a3eda5f72886220256 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Fri, 3 Mar 2023 11:16:12 -0500 Subject: [PATCH 0207/1058] Revert Intellifire breaking change from #88666 (#89110) --- homeassistant/components/intellifire/number.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index 1b0913f3f3a9..efa567d55cbd 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -26,8 +26,8 @@ async def async_setup_entry( coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] description = NumberEntityDescription( - key="flame_height", - name="Flame height", + key="flame_control", + name="Flame control", icon="mdi:arrow-expand-vertical", ) From 1bd9767d8ce8e554d55789648dcc38c8440998a5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Mar 2023 17:00:13 -1000 Subject: [PATCH 0208/1058] Handle InnoDB deadlocks during migration (#89073) * Handle slow InnoDB rollback when encountering duplicates during migration fixes #89069 * adjust * fix mock * tests * return on success --- .../components/recorder/migration.py | 21 ++++--- .../components/recorder/statistics.py | 6 +- homeassistant/components/recorder/util.py | 59 +++++++++++++++++-- tests/components/recorder/test_migrate.py | 23 +++++++- tests/components/recorder/test_purge.py | 4 +- tests/components/recorder/test_statistics.py | 3 +- 6 files changed, 97 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 431bc78ba801..0b8fe9243ba4 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -50,7 +50,7 @@ from .tasks import ( PostSchemaMigrationTask, StatisticsTimestampMigrationCleanupTask, ) -from .util import session_scope +from .util import database_job_retry_wrapper, session_scope if TYPE_CHECKING: from . import Recorder @@ -158,7 +158,9 @@ def migrate_schema( hass.add_job(instance.async_set_db_ready) new_version = version + 1 _LOGGER.info("Upgrading recorder db schema to version %s", new_version) - _apply_update(hass, engine, session_maker, new_version, current_version) + _apply_update( + instance, hass, engine, session_maker, new_version, current_version + ) with session_scope(session=session_maker()) as session: session.add(SchemaChanges(schema_version=new_version)) @@ -508,7 +510,9 @@ def _drop_foreign_key_constraints( ) +@database_job_retry_wrapper("Apply migration update", 10) def _apply_update( # noqa: C901 + instance: Recorder, hass: HomeAssistant, engine: Engine, session_maker: Callable[[], Session], @@ -922,7 +926,7 @@ def _apply_update( # noqa: C901 # There may be duplicated statistics entries, delete duplicates # and try again with session_scope(session=session_maker()) as session: - delete_statistics_duplicates(hass, session) + delete_statistics_duplicates(instance, hass, session) _migrate_statistics_columns_to_timestamp(session_maker, engine) # Log at error level to ensure the user sees this message in the log # since we logged the error above. @@ -965,7 +969,7 @@ def post_schema_migration( # since they are no longer used and take up a significant amount of space. assert instance.event_session is not None assert instance.engine is not None - _wipe_old_string_time_columns(instance.engine, instance.event_session) + _wipe_old_string_time_columns(instance, instance.engine, instance.event_session) if old_version < 35 <= new_version: # In version 34 we migrated all the created, start, and last_reset # columns to be timestamps. In version 34 we need to wipe the old columns @@ -978,7 +982,10 @@ def _wipe_old_string_statistics_columns(instance: Recorder) -> None: instance.queue_task(StatisticsTimestampMigrationCleanupTask()) -def _wipe_old_string_time_columns(engine: Engine, session: Session) -> None: +@database_job_retry_wrapper("Wipe old string time columns", 3) +def _wipe_old_string_time_columns( + instance: Recorder, engine: Engine, session: Session +) -> None: """Wipe old string time columns to save space.""" # Wipe Events.time_fired since its been replaced by Events.time_fired_ts # Wipe States.last_updated since its been replaced by States.last_updated_ts @@ -1162,7 +1169,7 @@ def _migrate_statistics_columns_to_timestamp( "last_reset_ts=" "UNIX_TIMESTAMP(last_reset) " "where start_ts is NULL " - "LIMIT 250000;" + "LIMIT 100000;" ) ) elif engine.dialect.name == SupportedDialect.POSTGRESQL: @@ -1180,7 +1187,7 @@ def _migrate_statistics_columns_to_timestamp( "created_ts=EXTRACT(EPOCH FROM created), " "last_reset_ts=EXTRACT(EPOCH FROM last_reset) " "where id IN ( " - f"SELECT id FROM {table} where start_ts is NULL LIMIT 250000 " + f"SELECT id FROM {table} where start_ts is NULL LIMIT 100000 " " );" ) ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2a958d3b622c..fee7be443b7a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -75,6 +75,7 @@ from .models import ( datetime_to_timestamp_or_none, ) from .util import ( + database_job_retry_wrapper, execute, execute_stmt_lambda_element, get_instance, @@ -515,7 +516,10 @@ def _delete_duplicates_from_table( return (total_deleted_rows, all_non_identical_duplicates) -def delete_statistics_duplicates(hass: HomeAssistant, session: Session) -> None: +@database_job_retry_wrapper("delete statistics duplicates", 3) +def delete_statistics_duplicates( + instance: Recorder, hass: HomeAssistant, session: Session +) -> None: """Identify and delete duplicated statistics. A backup will be made of duplicated statistics before it is deleted. diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 3ff6b62b21e5..bfdd8ff5b148 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -568,6 +568,17 @@ def end_incomplete_runs(session: Session, start_time: datetime) -> None: session.add(run) +def _is_retryable_error(instance: Recorder, err: OperationalError) -> bool: + """Return True if the error is retryable.""" + assert instance.engine is not None + return bool( + instance.engine.dialect.name == SupportedDialect.MYSQL + and isinstance(err.orig, BaseException) + and err.orig.args + and err.orig.args[0] in RETRYABLE_MYSQL_ERRORS + ) + + _FuncType = Callable[Concatenate[_RecorderT, _P], bool] @@ -585,12 +596,8 @@ def retryable_database_job( try: return job(instance, *args, **kwargs) except OperationalError as err: - assert instance.engine is not None - if ( - instance.engine.dialect.name == SupportedDialect.MYSQL - and err.orig - and err.orig.args[0] in RETRYABLE_MYSQL_ERRORS - ): + if _is_retryable_error(instance, err): + assert isinstance(err.orig, BaseException) _LOGGER.info( "%s; %s not completed, retrying", err.orig.args[1], description ) @@ -608,6 +615,46 @@ def retryable_database_job( return decorator +_WrappedFuncType = Callable[Concatenate[_RecorderT, _P], None] + + +def database_job_retry_wrapper( + description: str, attempts: int = 5 +) -> Callable[[_WrappedFuncType[_RecorderT, _P]], _WrappedFuncType[_RecorderT, _P]]: + """Try to execute a database job multiple times. + + This wrapper handles InnoDB deadlocks and lock timeouts. + + This is different from retryable_database_job in that it will retry the job + attempts number of times instead of returning False if the job fails. + """ + + def decorator( + job: _WrappedFuncType[_RecorderT, _P] + ) -> _WrappedFuncType[_RecorderT, _P]: + @functools.wraps(job) + def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> None: + for attempt in range(attempts): + try: + job(instance, *args, **kwargs) + return + except OperationalError as err: + if attempt == attempts - 1 or not _is_retryable_error( + instance, err + ): + raise + assert isinstance(err.orig, BaseException) + _LOGGER.info( + "%s; %s failed, retrying", err.orig.args[1], description + ) + time.sleep(instance.db_retry_wait) + # Failed with retryable error + + return wrapper + + return decorator + + def periodic_db_cleanups(instance: Recorder) -> None: """Run any database cleanups that need to happen periodically. diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 44c3ffac99ec..19c7e6c69558 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -69,7 +69,7 @@ async def test_schema_update_calls(recorder_db_url: str, hass: HomeAssistant) -> session_maker = instance.get_session update.assert_has_calls( [ - call(hass, engine, session_maker, version + 1, 0) + call(instance, hass, engine, session_maker, version + 1, 0) for version in range(0, db_schema.SCHEMA_VERSION) ] ) @@ -304,6 +304,8 @@ async def test_schema_migrate( migration_version = None real_migrate_schema = recorder.migration.migrate_schema real_apply_update = recorder.migration._apply_update + real_create_index = recorder.migration._create_index + create_calls = 0 def _create_engine_test(*args, **kwargs): """Test version of create_engine that initializes with old schema. @@ -355,6 +357,17 @@ async def test_schema_migrate( migration_stall.wait() real_apply_update(*args) + def _sometimes_failing_create_index(*args): + """Make the first index create raise a retryable error to ensure we retry.""" + if recorder_db_url.startswith("mysql://"): + nonlocal create_calls + if create_calls < 1: + create_calls += 1 + mysql_exception = OperationalError("statement", {}, []) + mysql_exception.orig = Exception(1205, "retryable") + raise mysql_exception + real_create_index(*args) + with patch("homeassistant.components.recorder.ALLOW_IN_MEMORY_DB", True), patch( "homeassistant.components.recorder.core.create_engine", new=_create_engine_test, @@ -368,6 +381,11 @@ async def test_schema_migrate( ), patch( "homeassistant.components.recorder.migration._apply_update", wraps=_instrument_apply_update, + ) as apply_update_mock, patch( + "homeassistant.components.recorder.util.time.sleep" + ), patch( + "homeassistant.components.recorder.migration._create_index", + wraps=_sometimes_failing_create_index, ), patch( "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", ), patch( @@ -394,12 +412,13 @@ async def test_schema_migrate( assert migration_version == db_schema.SCHEMA_VERSION assert setup_run.called assert recorder.util.async_migration_in_progress(hass) is not True + assert apply_update_mock.called def test_invalid_update(hass: HomeAssistant) -> None: """Test that an invalid new version raises an exception.""" with pytest.raises(ValueError): - migration._apply_update(hass, Mock(), Mock(), -1, 0) + migration._apply_update(Mock(), hass, Mock(), Mock(), -1, 0) @pytest.mark.parametrize( diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index c5ce8d272c7e..07c935129e94 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta import json import sqlite3 -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from sqlalchemy.exc import DatabaseError, OperationalError @@ -192,7 +192,7 @@ async def test_purge_old_states_encounters_temporary_mysql_error( await async_wait_recording_done(hass) mysql_exception = OperationalError("statement", {}, []) - mysql_exception.orig = MagicMock(args=(1205, "retryable")) + mysql_exception.orig = Exception(1205, "retryable") with patch( "homeassistant.components.recorder.util.time.sleep" diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 8685985def87..dd51946c86fe 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1231,8 +1231,9 @@ def test_delete_duplicates_no_duplicates( """Test removal of duplicated statistics.""" hass = hass_recorder() wait_recording_done(hass) + instance = recorder.get_instance(hass) with session_scope(hass=hass) as session: - delete_statistics_duplicates(hass, session) + delete_statistics_duplicates(instance, hass, session) assert "duplicated statistics rows" not in caplog.text assert "Found non identical" not in caplog.text assert "Found duplicated" not in caplog.text From b27b094e27bf7c7d8d41837d683f45b15cae3401 Mon Sep 17 00:00:00 2001 From: Bob van de Vijver Date: Sat, 4 Mar 2023 05:06:28 +0100 Subject: [PATCH 0209/1058] Add day to event end to correct TwenteMilieu event timespan (#89028) [TwenteMilieu] Add day to event end to correct event timespan Co-authored-by: Allen Porter --- homeassistant/components/twentemilieu/calendar.py | 6 +++--- tests/components/twentemilieu/snapshots/test_calendar.ambr | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py index d36850517345..e4ecbd9d866d 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -1,7 +1,7 @@ """Support for Twente Milieu Calendar.""" from __future__ import annotations -from datetime import date, datetime +from datetime import date, datetime, timedelta from twentemilieu import WasteType @@ -58,7 +58,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): CalendarEvent( summary=WASTE_TYPE_TO_DESCRIPTION[waste_type], start=waste_date, - end=waste_date, + end=waste_date + timedelta(days=1), ) for waste_date in waste_dates if start_date.date() <= waste_date <= end_date.date() @@ -89,7 +89,7 @@ class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEntity): self._event = CalendarEvent( summary=WASTE_TYPE_TO_DESCRIPTION[next_waste_pickup_type], start=next_waste_pickup_date, - end=next_waste_pickup_date, + end=next_waste_pickup_date + timedelta(days=1), ) super()._handle_coordinator_update() diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 04965b342bad..d004084e063c 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -12,7 +12,7 @@ dict({ 'description': None, 'end': dict({ - 'date': '2022-01-06', + 'date': '2022-01-07', }), 'location': None, 'recurrence_id': None, @@ -30,7 +30,7 @@ 'attributes': ReadOnlyDict({ 'all_day': True, 'description': '', - 'end_time': '2022-01-06 00:00:00', + 'end_time': '2022-01-07 00:00:00', 'friendly_name': 'Twente Milieu', 'icon': 'mdi:delete-empty', 'location': '', From bab758c951de5690a0b046e0d7faa1ef330c865b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Mar 2023 11:45:53 +0100 Subject: [PATCH 0210/1058] Refactor WLED button tests (#88580) --- .../wled/snapshots/test_button.ambr | 75 +++++++++++++++++++ tests/components/wled/test_button.py | 66 +++++++--------- 2 files changed, 104 insertions(+), 37 deletions(-) create mode 100644 tests/components/wled/snapshots/test_button.ambr diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr new file mode 100644 index 000000000000..da487b49489b --- /dev/null +++ b/tests/components/wled/snapshots/test_button.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_button_restart + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'WLED RGB Light Restart', + }), + 'context': , + 'entity_id': 'button.wled_rgb_light_restart', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_restart.1 + 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.wled_rgb_light_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_restart.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_button.py b/tests/components/wled/test_button.py index daa6839557b5..c1f3165e5bce 100644 --- a/tests/components/wled/test_button.py +++ b/tests/components/wled/test_button.py @@ -1,41 +1,41 @@ """Tests for the WLED button platform.""" from unittest.mock import MagicMock -from freezegun import freeze_time import pytest +from syrupy.assertion import SnapshotAssertion from wled import WLEDConnectionError, WLEDError -from homeassistant.components.button import ( - DOMAIN as BUTTON_DOMAIN, - SERVICE_PRESS, - ButtonDeviceClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - EntityCategory, -) +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("init_integration") +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2021-11-04 17:37:00+01:00"), +] async def test_button_restart( - hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_wled: MagicMock + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_wled: MagicMock, + snapshot: SnapshotAssertion, ) -> None: """Test the creation and values of the WLED button.""" assert (state := hass.states.get("button.wled_rgb_light_restart")) + assert state == snapshot + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + assert state.state == STATE_UNKNOWN - assert state.attributes[ATTR_DEVICE_CLASS] == ButtonDeviceClass.RESTART - - assert (entry := entity_registry.async_get("button.wled_rgb_light_restart")) - assert entry.unique_id == "aabbccddeeff_restart" - assert entry.entity_category is EntityCategory.CONFIG - await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, @@ -45,15 +45,11 @@ async def test_button_restart( assert mock_wled.reset.call_count == 1 mock_wled.reset.assert_called_with() + assert (state := hass.states.get("button.wled_rgb_light_restart")) + assert state.state == "2021-11-04T16:37:00+00:00" -@freeze_time("2021-11-04 17:37:00", tz_offset=-1) -async def test_button_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED buttons.""" + # Test with WLED error mock_wled.reset.side_effect = WLEDError - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): await hass.services.async_call( BUTTON_DOMAIN, @@ -63,17 +59,12 @@ async def test_button_error( ) await hass.async_block_till_done() + # Ensure this didn't made the entity unavailable assert (state := hass.states.get("button.wled_rgb_light_restart")) - assert state.state == "2021-11-04T16:37:00+00:00" + assert state.state != STATE_UNAVAILABLE - -async def test_button_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED buttons.""" + # Test with WLED connection error mock_wled.reset.side_effect = WLEDConnectionError - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): await hass.services.async_call( BUTTON_DOMAIN, @@ -82,5 +73,6 @@ async def test_button_connection_error( blocking=True, ) + # Ensure this made the entity unavailable assert (state := hass.states.get("button.wled_rgb_light_restart")) assert state.state == STATE_UNAVAILABLE From 2e5801cb6d6ea44fc8cab25d5068eed45b4ecc2c Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 4 Mar 2023 12:05:41 +0100 Subject: [PATCH 0211/1058] Bump pyfibaro version to 0.6.9 (#89120) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 6522d3b06ed3..6dd2104bd9b3 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.6.8"] + "requirements": ["pyfibaro==0.6.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 778258f4e184..5c01403688ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1621,7 +1621,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.0.9 # homeassistant.components.fibaro -pyfibaro==0.6.8 +pyfibaro==0.6.9 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ebbbe753663..b458207aacc5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1161,7 +1161,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.0.9 # homeassistant.components.fibaro -pyfibaro==0.6.8 +pyfibaro==0.6.9 # homeassistant.components.fido pyfido==2.1.2 From 5e2b7c63779c13eb7e3a71bf4d29e4e1d638a2a6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 4 Mar 2023 15:09:47 +0100 Subject: [PATCH 0212/1058] Refactor WLED diagnostic tests (#88581) --- .../wled/snapshots/test_diagnostics.ambr | 198 +++++++++++++++ tests/components/wled/test_diagnostics.py | 229 +----------------- 2 files changed, 205 insertions(+), 222 deletions(-) create mode 100644 tests/components/wled/snapshots/test_diagnostics.ambr diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..e06608033ca0 --- /dev/null +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -0,0 +1,198 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'effects': dict({ + '0': 'Solid', + '1': 'Blink', + '10': 'Scan', + '11': 'Dual Scan', + '12': 'Fade', + '13': 'Chase', + '14': 'Chase Rainbow', + '15': 'Running', + '16': 'Saw', + '17': 'Twinkle', + '18': 'Dissolve', + '19': 'Dissolve Rnd', + '2': 'Breathe', + '20': 'Sparkle', + '21': 'Dark Sparkle', + '22': 'Sparkle+', + '23': 'Strobe', + '24': 'Strobe Rainbow', + '25': 'Mega Strobe', + '26': 'Blink Rainbow', + '27': 'Android', + '28': 'Chase', + '29': 'Chase Random', + '3': 'Wipe', + '30': 'Chase Rainbow', + '31': 'Chase Flash', + '32': 'Chase Flash Rnd', + '33': 'Rainbow Runner', + '34': 'Colorful', + '35': 'Traffic Light', + '36': 'Sweep Random', + '37': 'Running 2', + '38': 'Red & Blue', + '39': 'Stream', + '4': 'Wipe Random', + '40': 'Scanner', + '41': 'Lighthouse', + '42': 'Fireworks', + '43': 'Rain', + '44': 'Merry Christmas', + '45': 'Fire Flicker', + '46': 'Gradient', + '47': 'Loading', + '48': 'In Out', + '49': 'In In', + '5': 'Random Colors', + '50': 'Out Out', + '51': 'Out In', + '52': 'Circus', + '53': 'Halloween', + '54': 'Tri Chase', + '55': 'Tri Wipe', + '56': 'Tri Fade', + '57': 'Lightning', + '58': 'ICU', + '59': 'Multi Comet', + '6': 'Sweep', + '60': 'Dual Scanner', + '61': 'Stream 2', + '62': 'Oscillate', + '63': 'Pride 2015', + '64': 'Juggle', + '65': 'Palette', + '66': 'Fire 2012', + '67': 'Colorwaves', + '68': 'BPM', + '69': 'Fill Noise', + '7': 'Dynamic', + '70': 'Noise 1', + '71': 'Noise 2', + '72': 'Noise 3', + '73': 'Noise 4', + '74': 'Colortwinkle', + '75': 'Lake', + '76': 'Meteor', + '77': 'Smooth Meteor', + '78': 'Railway', + '79': 'Ripple', + '8': 'Colorloop', + '80': 'Twinklefox', + '9': 'Rainbow', + }), + 'info': dict({ + 'architecture': 'esp8266', + 'arduino_core_version': '2.4.2', + 'brand': 'WLED', + 'build_type': 'bin', + 'effect_count': 81, + 'filesystem': None, + 'free_heap': 14600, + 'leds': dict({ + '__type': "", + 'repr': 'Leds(cct=False, count=30, fps=None, light_capabilities=None, max_power=850, max_segments=10, power=470, rgbw=False, wv=True, segment_light_capabilities=None)', + }), + 'live': False, + 'live_ip': 'Unknown', + 'live_mode': 'Unknown', + 'mac_address': 'aabbccddeeff', + 'name': 'WLED RGB Light', + 'pallet_count': 50, + 'product': 'DIY light', + 'udp_port': 21324, + 'uptime': 32, + 'version': '0.8.5', + 'version_id': 1909122, + 'version_latest_beta': '0.13.0b1', + 'version_latest_stable': '0.12.0', + 'websocket': None, + 'wifi': '**REDACTED**', + }), + 'palettes': dict({ + '0': 'Default', + '1': 'Random Cycle', + '10': 'Forest', + '11': 'Rainbow', + '12': 'Rainbow Bands', + '13': 'Sunset', + '14': 'Rivendell', + '15': 'Breeze', + '16': 'Red & Blue', + '17': 'Yellowout', + '18': 'Analogous', + '19': 'Splash', + '2': 'Primary Color', + '20': 'Pastel', + '21': 'Sunset 2', + '22': 'Beech', + '23': 'Vintage', + '24': 'Departure', + '25': 'Landscape', + '26': 'Beach', + '27': 'Sherbet', + '28': 'Hult', + '29': 'Hult 64', + '3': 'Based on Primary', + '30': 'Drywet', + '31': 'Jul', + '32': 'Grintage', + '33': 'Rewhi', + '34': 'Tertiary', + '35': 'Fire', + '36': 'Icefire', + '37': 'Cyane', + '38': 'Light Pink', + '39': 'Autumn', + '4': 'Set Colors', + '40': 'Magenta', + '41': 'Magred', + '42': 'Yelmag', + '43': 'Yelblu', + '44': 'Orange & Teal', + '45': 'Tiamat', + '46': 'April Night', + '47': 'Orangery', + '48': 'C9', + '49': 'Sakura', + '5': 'Based on Set', + '6': 'Party', + '7': 'Cloud', + '8': 'Lava', + '9': 'Ocean', + }), + 'playlists': dict({ + }), + 'presets': dict({ + }), + 'state': dict({ + 'brightness': 127, + 'lor': 0, + 'nightlight': dict({ + '__type': "", + 'repr': 'Nightlight(duration=60, fade=True, on=False, mode=, target_brightness=0)', + }), + 'on': True, + 'playlist': -1, + 'preset': -1, + 'segments': list([ + dict({ + '__type': "", + 'repr': "Segment(brightness=127, clones=-1, color_primary=(255, 159, 0), color_secondary=(0, 0, 0), color_tertiary=(0, 0, 0), effect=Effect(effect_id=0, name='Solid'), intensity=128, length=20, on=True, palette=Palette(name='Default', palette_id=0), reverse=False, segment_id=0, selected=True, speed=32, start=0, stop=19)", + }), + dict({ + '__type': "", + 'repr': "Segment(brightness=127, clones=-1, color_primary=(0, 255, 123), color_secondary=(0, 0, 0), color_tertiary=(0, 0, 0), effect=Effect(effect_id=1, name='Blink'), intensity=64, length=10, on=True, palette=Palette(name='Random Cycle', palette_id=1), reverse=True, segment_id=1, selected=True, speed=16, start=20, stop=30)", + }), + ]), + 'sync': dict({ + '__type': "", + 'repr': 'Sync(receive=True, send=False)', + }), + 'transition': 7, + }), + }) +# --- diff --git a/tests/components/wled/test_diagnostics.py b/tests/components/wled/test_diagnostics.py index fd6e156371bb..38e7ebe3e25f 100644 --- a/tests/components/wled/test_diagnostics.py +++ b/tests/components/wled/test_diagnostics.py @@ -1,4 +1,6 @@ """Tests for the diagnostics data provided by the WLED integration.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -10,227 +12,10 @@ 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 - ) == { - "info": { - "architecture": "esp8266", - "arduino_core_version": "2.4.2", - "brand": "WLED", - "build_type": "bin", - "effect_count": 81, - "filesystem": None, - "free_heap": 14600, - "leds": { - "__type": "", - "repr": ( - "Leds(cct=False, count=30, fps=None, light_capabilities=None, " - "max_power=850, max_segments=10, power=470, rgbw=False, wv=True, " - "segment_light_capabilities=None)" - ), - }, - "live_ip": "Unknown", - "live_mode": "Unknown", - "live": False, - "mac_address": "aabbccddeeff", - "name": "WLED RGB Light", - "pallet_count": 50, - "product": "DIY light", - "udp_port": 21324, - "uptime": 32, - "version_id": 1909122, - "version": "0.8.5", - "version_latest_beta": "0.13.0b1", - "version_latest_stable": "0.12.0", - "websocket": None, - "wifi": "**REDACTED**", - }, - "state": { - "brightness": 127, - "nightlight": { - "__type": "", - "repr": ( - "Nightlight(duration=60, fade=True, on=False," - " mode=, target_brightness=0)" - ), - }, - "on": True, - "playlist": -1, - "preset": -1, - "segments": [ - { - "__type": "", - "repr": ( - "Segment(brightness=127, clones=-1," - " color_primary=(255, 159, 0)," - " color_secondary=(0, 0, 0)," - " color_tertiary=(0, 0, 0)," - " effect=Effect(effect_id=0, name='Solid')," - " intensity=128, length=20, on=True," - " palette=Palette(name='Default', palette_id=0)," - " reverse=False, segment_id=0, selected=True," - " speed=32, start=0, stop=19)" - ), - }, - { - "__type": "", - "repr": ( - "Segment(brightness=127, clones=-1," - " color_primary=(0, 255, 123)," - " color_secondary=(0, 0, 0)," - " color_tertiary=(0, 0, 0)," - " effect=Effect(effect_id=1, name='Blink')," - " intensity=64, length=10, on=True," - " palette=Palette(name='Random Cycle', palette_id=1)," - " reverse=True, segment_id=1, selected=True," - " speed=16, start=20, stop=30)" - ), - }, - ], - "sync": { - "__type": "", - "repr": "Sync(receive=True, send=False)", - }, - "transition": 7, - "lor": 0, - }, - "effects": { - "27": "Android", - "68": "BPM", - "1": "Blink", - "26": "Blink Rainbow", - "2": "Breathe", - "13": "Chase", - "28": "Chase", - "31": "Chase Flash", - "32": "Chase Flash Rnd", - "14": "Chase Rainbow", - "30": "Chase Rainbow", - "29": "Chase Random", - "52": "Circus", - "34": "Colorful", - "8": "Colorloop", - "74": "Colortwinkle", - "67": "Colorwaves", - "21": "Dark Sparkle", - "18": "Dissolve", - "19": "Dissolve Rnd", - "11": "Dual Scan", - "60": "Dual Scanner", - "7": "Dynamic", - "12": "Fade", - "69": "Fill Noise", - "66": "Fire 2012", - "45": "Fire Flicker", - "42": "Fireworks", - "46": "Gradient", - "53": "Halloween", - "58": "ICU", - "49": "In In", - "48": "In Out", - "64": "Juggle", - "75": "Lake", - "41": "Lighthouse", - "57": "Lightning", - "47": "Loading", - "25": "Mega Strobe", - "44": "Merry Christmas", - "76": "Meteor", - "59": "Multi Comet", - "70": "Noise 1", - "71": "Noise 2", - "72": "Noise 3", - "73": "Noise 4", - "62": "Oscillate", - "51": "Out In", - "50": "Out Out", - "65": "Palette", - "63": "Pride 2015", - "78": "Railway", - "43": "Rain", - "9": "Rainbow", - "33": "Rainbow Runner", - "5": "Random Colors", - "38": "Red & Blue", - "79": "Ripple", - "15": "Running", - "37": "Running 2", - "16": "Saw", - "10": "Scan", - "40": "Scanner", - "77": "Smooth Meteor", - "0": "Solid", - "20": "Sparkle", - "22": "Sparkle+", - "39": "Stream", - "61": "Stream 2", - "23": "Strobe", - "24": "Strobe Rainbow", - "6": "Sweep", - "36": "Sweep Random", - "35": "Traffic Light", - "54": "Tri Chase", - "56": "Tri Fade", - "55": "Tri Wipe", - "17": "Twinkle", - "80": "Twinklefox", - "3": "Wipe", - "4": "Wipe Random", - }, - "palettes": { - "18": "Analogous", - "46": "April Night", - "39": "Autumn", - "3": "Based on Primary", - "5": "Based on Set", - "26": "Beach", - "22": "Beech", - "15": "Breeze", - "48": "C9", - "7": "Cloud", - "37": "Cyane", - "0": "Default", - "24": "Departure", - "30": "Drywet", - "35": "Fire", - "10": "Forest", - "32": "Grintage", - "28": "Hult", - "29": "Hult 64", - "36": "Icefire", - "31": "Jul", - "25": "Landscape", - "8": "Lava", - "38": "Light Pink", - "40": "Magenta", - "41": "Magred", - "9": "Ocean", - "44": "Orange & Teal", - "47": "Orangery", - "6": "Party", - "20": "Pastel", - "2": "Primary Color", - "11": "Rainbow", - "12": "Rainbow Bands", - "1": "Random Cycle", - "16": "Red & Blue", - "33": "Rewhi", - "14": "Rivendell", - "49": "Sakura", - "4": "Set Colors", - "27": "Sherbet", - "19": "Splash", - "13": "Sunset", - "21": "Sunset 2", - "34": "Tertiary", - "45": "Tiamat", - "23": "Vintage", - "43": "Yelblu", - "17": "Yellowout", - "42": "Yelmag", - }, - "playlists": {}, - "presets": {}, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 34f8e94ca9be9d660751cb259dea1045ffe39a5d Mon Sep 17 00:00:00 2001 From: Garrett <7310260+G-Two@users.noreply.github.com> Date: Sat, 4 Mar 2023 16:26:16 -0500 Subject: [PATCH 0213/1058] Bump subarulink to 0.7.5 (#89162) --- homeassistant/components/subaru/__init__.py | 7 ++-- homeassistant/components/subaru/const.py | 3 +- homeassistant/components/subaru/manifest.json | 2 +- homeassistant/components/subaru/sensor.py | 33 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/subaru/api_responses.py | 25 ++++++++++---- tests/components/subaru/conftest.py | 14 ++++++++ .../fixtures/diagnostics_config_entry.json | 2 +- .../subaru/fixtures/diagnostics_device.json | 2 +- tests/components/subaru/test_init.py | 14 ++++---- 11 files changed, 71 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 3e72b079adfc..49ad3cf0d983 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -66,7 +66,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: vehicle_info = {} for vin in controller.get_vehicles(): - vehicle_info[vin] = get_vehicle_info(controller, vin) + if controller.get_subscription_status(vin): + vehicle_info[vin] = get_vehicle_info(controller, vin) async def async_update_data(): """Fetch data from API endpoint.""" @@ -116,10 +117,6 @@ async def refresh_subaru_data(config_entry, vehicle_info, controller): for vehicle in vehicle_info.values(): vin = vehicle[VEHICLE_VIN] - # Active subscription required - if not vehicle[VEHICLE_HAS_SAFETY_SERVICE]: - continue - # Optionally send an "update" remote command to vehicle (throttled with update_interval) if config_entry.options.get(CONF_UPDATE_ENABLED, False): await update_subaru(vehicle, controller) diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 3de4930a6917..42badfc0185c 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -28,11 +28,12 @@ VEHICLE_HAS_REMOTE_START = "has_res" VEHICLE_HAS_REMOTE_SERVICE = "has_remote" VEHICLE_HAS_SAFETY_SERVICE = "has_safety" VEHICLE_LAST_UPDATE = "last_update" -VEHICLE_STATUS = "status" +VEHICLE_STATUS = "vehicle_status" API_GEN_1 = "g1" API_GEN_2 = "g2" +API_GEN_3 = "g3" MANUFACTURER = "Subaru" PLATFORMS = [ diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 1aade9465439..5852136ca456 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.0"] + "requirements": ["subarulink==0.7.5"] } diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 5479f56cf969..6c8e8fc100b1 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -31,12 +31,12 @@ from homeassistant.util.unit_system import ( from . import get_device_info from .const import ( API_GEN_2, + API_GEN_3, DOMAIN, ENTRY_COORDINATOR, ENTRY_VEHICLES, VEHICLE_API_GEN, VEHICLE_HAS_EV, - VEHICLE_HAS_SAFETY_SERVICE, VEHICLE_STATUS, VEHICLE_VIN, ) @@ -51,7 +51,7 @@ FUEL_CONSUMPTION_MILES_PER_GALLON = "mi/gal" L_PER_GAL = VolumeConverter.convert(1, UnitOfVolume.GALLONS, UnitOfVolume.LITERS) KM_PER_MI = DistanceConverter.convert(1, UnitOfLength.MILES, UnitOfLength.KILOMETERS) -# Sensor available to "Subaru Safety Plus" subscribers with Gen1 or Gen2 vehicles +# Sensor available for Gen1 or Gen2 vehicles SAFETY_SENSORS = [ SensorEntityDescription( key=sc.ODOMETER, @@ -63,7 +63,7 @@ SAFETY_SENSORS = [ ), ] -# Sensors available to "Subaru Safety Plus" subscribers with Gen2 vehicles +# Sensors available to subscribers with Gen2/Gen3 vehicles API_GEN_2_SENSORS = [ SensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, @@ -110,7 +110,18 @@ API_GEN_2_SENSORS = [ ), ] -# Sensors available to "Subaru Safety Plus" subscribers with PHEV vehicles +# Sensors available for Gen3 vehicles +API_GEN_3_SENSORS = [ + SensorEntityDescription( + key=sc.REMAINING_FUEL_PERCENT, + icon="mdi:gas-station", + name="Fuel level", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +] + +# Sensors available to subscribers with PHEV vehicles EV_SENSORS = [ SensorEntityDescription( key=sc.EV_DISTANCE_TO_EMPTY, @@ -156,14 +167,16 @@ def create_vehicle_sensors( ) -> list[SubaruSensor]: """Instantiate all available sensors for the vehicle.""" sensor_descriptions_to_add = [] - if vehicle_info[VEHICLE_HAS_SAFETY_SERVICE]: - sensor_descriptions_to_add.extend(SAFETY_SENSORS) + sensor_descriptions_to_add.extend(SAFETY_SENSORS) - if vehicle_info[VEHICLE_API_GEN] == API_GEN_2: - sensor_descriptions_to_add.extend(API_GEN_2_SENSORS) + if vehicle_info[VEHICLE_API_GEN] in [API_GEN_2, API_GEN_3]: + sensor_descriptions_to_add.extend(API_GEN_2_SENSORS) - if vehicle_info[VEHICLE_HAS_EV]: - sensor_descriptions_to_add.extend(EV_SENSORS) + if vehicle_info[VEHICLE_API_GEN] == API_GEN_3: + sensor_descriptions_to_add.extend(API_GEN_3_SENSORS) + + if vehicle_info[VEHICLE_HAS_EV]: + sensor_descriptions_to_add.extend(EV_SENSORS) return [ SubaruSensor( diff --git a/requirements_all.txt b/requirements_all.txt index 5c01403688ee..96bd1d20f0b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2434,7 +2434,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.0 +subarulink==0.7.5 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b458207aacc5..896ded1d1ca3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1734,7 +1734,7 @@ stookwijzer==1.3.0 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.0 +subarulink==0.7.5 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index 315530c15b35..e2fdf9ae508b 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -5,22 +5,28 @@ from datetime import datetime, timezone from homeassistant.components.subaru.const import ( API_GEN_1, API_GEN_2, + API_GEN_3, VEHICLE_API_GEN, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_MODEL_NAME, + VEHICLE_MODEL_YEAR, VEHICLE_NAME, + VEHICLE_STATUS, VEHICLE_VIN, ) TEST_VIN_1_G1 = "JF2ABCDE6L0000001" TEST_VIN_2_EV = "JF2ABCDE6L0000002" -TEST_VIN_3_G2 = "JF2ABCDE6L0000003" +TEST_VIN_3_G3 = "JF2ABCDE6L0000003" VEHICLE_DATA = { TEST_VIN_1_G1: { VEHICLE_VIN: TEST_VIN_1_G1, + VEHICLE_MODEL_YEAR: "2017", + VEHICLE_MODEL_NAME: "Outback", VEHICLE_NAME: "test_vehicle_1", VEHICLE_HAS_EV: False, VEHICLE_API_GEN: API_GEN_1, @@ -30,6 +36,8 @@ VEHICLE_DATA = { }, TEST_VIN_2_EV: { VEHICLE_VIN: TEST_VIN_2_EV, + VEHICLE_MODEL_YEAR: "2019", + VEHICLE_MODEL_NAME: "Crosstrek", VEHICLE_NAME: "test_vehicle_2", VEHICLE_HAS_EV: True, VEHICLE_API_GEN: API_GEN_2, @@ -37,11 +45,13 @@ VEHICLE_DATA = { VEHICLE_HAS_REMOTE_SERVICE: True, VEHICLE_HAS_SAFETY_SERVICE: True, }, - TEST_VIN_3_G2: { - VEHICLE_VIN: TEST_VIN_3_G2, + TEST_VIN_3_G3: { + VEHICLE_VIN: TEST_VIN_3_G3, + VEHICLE_MODEL_YEAR: "2022", + VEHICLE_MODEL_NAME: "Ascent", VEHICLE_NAME: "test_vehicle_3", VEHICLE_HAS_EV: False, - VEHICLE_API_GEN: API_GEN_2, + VEHICLE_API_GEN: API_GEN_3, VEHICLE_HAS_REMOTE_START: True, VEHICLE_HAS_REMOTE_SERVICE: True, VEHICLE_HAS_SAFETY_SERVICE: True, @@ -51,7 +61,7 @@ VEHICLE_DATA = { MOCK_DATETIME = datetime.fromtimestamp(1595560000, timezone.utc) VEHICLE_STATUS_EV = { - "status": { + VEHICLE_STATUS: { "AVG_FUEL_CONSUMPTION": 2.3, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", @@ -120,8 +130,8 @@ VEHICLE_STATUS_EV = { } -VEHICLE_STATUS_G2 = { - "status": { +VEHICLE_STATUS_G3 = { + VEHICLE_STATUS: { "AVG_FUEL_CONSUMPTION": 2.3, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", @@ -136,6 +146,7 @@ VEHICLE_STATUS_G2 = { "DOOR_REAR_LEFT_POSITION": "CLOSED", "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", + "REMAINING_FUEL_PERCENT": 77, "ODOMETER": 1234, "POSITION_HEADING_DEGREE": 150, "POSITION_SPEED_KMPH": "0", diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 94b803fea010..678e8ba50342 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -17,6 +17,8 @@ from homeassistant.components.subaru.const import ( VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_MODEL_NAME, + VEHICLE_MODEL_YEAR, VEHICLE_NAME, ) from homeassistant.config_entries import ConfigEntryState @@ -40,10 +42,13 @@ MOCK_API_UPDATE_SAVED_PIN = f"{MOCK_API}update_saved_pin" MOCK_API_GET_VEHICLES = f"{MOCK_API}get_vehicles" MOCK_API_VIN_TO_NAME = f"{MOCK_API}vin_to_name" MOCK_API_GET_API_GEN = f"{MOCK_API}get_api_gen" +MOCK_API_GET_MODEL_NAME = f"{MOCK_API}get_model_name" +MOCK_API_GET_MODEL_YEAR = f"{MOCK_API}get_model_year" MOCK_API_GET_EV_STATUS = f"{MOCK_API}get_ev_status" MOCK_API_GET_RES_STATUS = f"{MOCK_API}get_res_status" MOCK_API_GET_REMOTE_STATUS = f"{MOCK_API}get_remote_status" MOCK_API_GET_SAFETY_STATUS = f"{MOCK_API}get_safety_status" +MOCK_API_GET_SUBSCRIPTION_STATUS = f"{MOCK_API}get_subscription_status" MOCK_API_GET_DATA = f"{MOCK_API}get_data" MOCK_API_UPDATE = f"{MOCK_API}update" MOCK_API_FETCH = f"{MOCK_API}fetch" @@ -114,6 +119,12 @@ async def setup_subaru_config_entry( ), patch( MOCK_API_GET_API_GEN, return_value=vehicle_data[VEHICLE_API_GEN], + ), patch( + MOCK_API_GET_MODEL_NAME, + return_value=vehicle_data[VEHICLE_MODEL_NAME], + ), patch( + MOCK_API_GET_MODEL_YEAR, + return_value=vehicle_data[VEHICLE_MODEL_YEAR], ), patch( MOCK_API_GET_EV_STATUS, return_value=vehicle_data[VEHICLE_HAS_EV], @@ -126,6 +137,9 @@ async def setup_subaru_config_entry( ), patch( MOCK_API_GET_SAFETY_STATUS, return_value=vehicle_data[VEHICLE_HAS_SAFETY_SERVICE], + ), patch( + MOCK_API_GET_SUBSCRIPTION_STATUS, + return_value=True, ), patch( MOCK_API_GET_DATA, return_value=vehicle_status, diff --git a/tests/components/subaru/fixtures/diagnostics_config_entry.json b/tests/components/subaru/fixtures/diagnostics_config_entry.json index 32e9ac070beb..327b0c481741 100644 --- a/tests/components/subaru/fixtures/diagnostics_config_entry.json +++ b/tests/components/subaru/fixtures/diagnostics_config_entry.json @@ -11,7 +11,7 @@ }, "data": [ { - "status": { + "vehicle_status": { "AVG_FUEL_CONSUMPTION": 2.3, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", diff --git a/tests/components/subaru/fixtures/diagnostics_device.json b/tests/components/subaru/fixtures/diagnostics_device.json index c3762925d042..f67be94a1715 100644 --- a/tests/components/subaru/fixtures/diagnostics_device.json +++ b/tests/components/subaru/fixtures/diagnostics_device.json @@ -10,7 +10,7 @@ "update_enabled": true }, "data": { - "status": { + "vehicle_status": { "AVG_FUEL_CONSUMPTION": 2.3, "DISTANCE_TO_EMPTY_FUEL": 707, "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", diff --git a/tests/components/subaru/test_init.py b/tests/components/subaru/test_init.py index 1723bd062fc8..e82d7a1d72c8 100644 --- a/tests/components/subaru/test_init.py +++ b/tests/components/subaru/test_init.py @@ -16,10 +16,10 @@ from homeassistant.setup import async_setup_component from .api_responses import ( TEST_VIN_1_G1, TEST_VIN_2_EV, - TEST_VIN_3_G2, + TEST_VIN_3_G3, VEHICLE_DATA, VEHICLE_STATUS_EV, - VEHICLE_STATUS_G2, + VEHICLE_STATUS_G3, ) from .conftest import ( MOCK_API_FETCH, @@ -43,14 +43,14 @@ async def test_setup_ev(hass: HomeAssistant, ev_entry) -> None: assert check_entry.state is ConfigEntryState.LOADED -async def test_setup_g2(hass: HomeAssistant, subaru_config_entry) -> None: - """Test setup with a G2 vehcile .""" +async def test_setup_g3(hass: HomeAssistant, subaru_config_entry) -> None: + """Test setup with a G3 vehicle .""" await setup_subaru_config_entry( hass, subaru_config_entry, - vehicle_list=[TEST_VIN_3_G2], - vehicle_data=VEHICLE_DATA[TEST_VIN_3_G2], - vehicle_status=VEHICLE_STATUS_G2, + vehicle_list=[TEST_VIN_3_G3], + vehicle_data=VEHICLE_DATA[TEST_VIN_3_G3], + vehicle_status=VEHICLE_STATUS_G3, ) check_entry = hass.config_entries.async_get_entry(subaru_config_entry.entry_id) assert check_entry From bfadc8453d6a396200cbc15f5027bfd6469d067d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 5 Mar 2023 02:41:31 +0100 Subject: [PATCH 0214/1058] Clean up import/migration repair in LaMetric (#89153) --- homeassistant/components/lametric/__init__.py | 31 ++----------------- .../components/lametric/strings.json | 6 ---- tests/components/lametric/test_init.py | 22 ------------- 3 files changed, 2 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 5fd531234b89..867b80cf4087 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -1,50 +1,23 @@ """Support for LaMetric time.""" -import voluptuous as vol - from homeassistant.components import notify as hass_notify from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_NAME, Platform +from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS from .coordinator import LaMetricDataUpdateCoordinator from .services import async_setup_services -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.removed(DOMAIN, raise_if_present=False) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LaMetric integration.""" async_setup_services(hass) hass.data[DOMAIN] = {"hass_config": config} - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "manual_migration", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="manual_migration", - ) - return True diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index f20732c63486..eb90b21ff20f 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -44,12 +44,6 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "issues": { - "manual_migration": { - "title": "Manual migration required for LaMetric", - "description": "The LaMetric integration has been modernized: It is now configured and set up via the user interface and the communcations are now local.\n\nUnfortunately, there is no automatic migration path possible and thus requires you to re-set up your LaMetric with Home Assistant. Please consult the Home Assistant LaMetric integration documentation on how to set it up.\n\nRemove the old LaMetric YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "entity": { "select": { "brightness_mode": { diff --git a/tests/components/lametric/test_init.py b/tests/components/lametric/test_init.py index 50695fc4e55b..eee09b3acce1 100644 --- a/tests/components/lametric/test_init.py +++ b/tests/components/lametric/test_init.py @@ -1,8 +1,6 @@ """Tests for the LaMetric integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock -from aiohttp import ClientWebSocketResponse from demetriek import ( LaMetricAuthenticationError, LaMetricConnectionError, @@ -12,12 +10,9 @@ import pytest from homeassistant.components.lametric.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.repairs import get_repairs async def test_load_unload_config_entry( @@ -59,23 +54,6 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_yaml_config_raises_repairs( - hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], - caplog: pytest.LogCaptureFixture, -) -> None: - """Test that YAML configuration raises an repairs issue.""" - await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_CLIENT_ID: "foo", CONF_CLIENT_SECRET: "bar"}} - ) - - assert "The 'lametric' option is deprecated" in caplog.text - - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "manual_migration" - - async def test_config_entry_authentication_failed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From ec0223f32679abb7ab6494e72bfa673a001bc241 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 5 Mar 2023 05:38:26 +0100 Subject: [PATCH 0215/1058] Cleanup plex config flow tests (#88991) --- tests/components/plex/test_config_flow.py | 219 +++++++++------------- tests/components/plex/test_init.py | 22 +++ 2 files changed, 115 insertions(+), 126 deletions(-) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 288c95dfbf1f..36c9ab614f53 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -39,6 +39,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from .const import DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN, PLEX_DIRECT_URL from .helpers import trigger_plex_update, wait_for_debouncer @@ -55,7 +56,7 @@ async def test_bad_credentials( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch( @@ -66,14 +67,14 @@ async def test_bad_credentials( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"][CONF_TOKEN] == "faulty_credentials" @@ -85,7 +86,7 @@ async def test_bad_hostname( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch( @@ -97,14 +98,14 @@ async def test_bad_hostname( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"][CONF_HOST] == "not_found" @@ -116,7 +117,7 @@ async def test_unknown_exception( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexapi.myplex.MyPlexAccount", side_effect=Exception), patch( @@ -125,13 +126,13 @@ async def test_unknown_exception( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unknown" @@ -148,7 +149,7 @@ async def test_no_servers_found( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -157,13 +158,13 @@ async def test_no_servers_found( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "no_servers" @@ -175,7 +176,7 @@ async def test_single_available_server( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -184,25 +185,22 @@ async def test_single_available_server( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - - assert result["title"] == mock_plex_server.url_in_use - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert ( - result["data"][CONF_SERVER_IDENTIFIER] - == mock_plex_server.machine_identifier + result["title"] == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" assert ( - result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use + result["data"][PLEX_SERVER_CONFIG][CONF_URL] + == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -220,7 +218,7 @@ async def test_multiple_servers_with_selection( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" requests_mock.get( @@ -233,13 +231,13 @@ async def test_multiple_servers_with_selection( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "select_server" result = await hass.config_entries.flow.async_configure( @@ -248,19 +246,16 @@ async def test_multiple_servers_with_selection( CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][CONF_SERVER_IDENTIFIER] }, ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - - assert result["title"] == mock_plex_server.url_in_use - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert ( - result["data"][CONF_SERVER_IDENTIFIER] - == mock_plex_server.machine_identifier + result["title"] == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" assert ( - result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use + result["data"][PLEX_SERVER_CONFIG][CONF_URL] + == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -286,7 +281,7 @@ async def test_adding_last_unconfigured_server( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" requests_mock.get( @@ -300,25 +295,22 @@ async def test_adding_last_unconfigured_server( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - - assert result["title"] == mock_plex_server.url_in_use - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert ( - result["data"][CONF_SERVER_IDENTIFIER] - == mock_plex_server.machine_identifier + result["title"] == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" assert ( - result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use + result["data"][PLEX_SERVER_CONFIG][CONF_URL] + == "https://1-2-3-4.123456789001234567890.plex.direct:32400" ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -347,7 +339,7 @@ async def test_all_available_servers_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" requests_mock.get("https://plex.tv/users/account", text=plextv_account) @@ -362,13 +354,13 @@ async def test_all_available_servers_configured( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "all_configured" @@ -380,7 +372,7 @@ async def test_option_flow(hass: HomeAssistant, entry, mock_plex_server) -> None result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "plex_mp_settings" result = await hass.config_entries.options.async_configure( @@ -391,7 +383,7 @@ async def test_option_flow(hass: HomeAssistant, entry, mock_plex_server) -> None CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { Platform.MEDIA_PLAYER: { CONF_USE_EPISODE_ART: True, @@ -414,7 +406,7 @@ async def test_missing_option_flow( result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "plex_mp_settings" result = await hass.config_entries.options.async_configure( @@ -425,7 +417,7 @@ async def test_missing_option_flow( CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { Platform.MEDIA_PLAYER: { CONF_USE_EPISODE_ART: True, @@ -451,7 +443,7 @@ async def test_option_flow_new_users_available( mock_plex_server = await setup_plex_server(config_entry=entry) await hass.async_block_till_done() - server_id = mock_plex_server.machine_identifier + server_id = "unique_id_123" monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users new_users = [x for x in mock_plex_server.accounts if x not in monitored_users] @@ -461,7 +453,7 @@ async def test_option_flow_new_users_available( result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "plex_mp_settings" multiselect_defaults = result["data_schema"].schema["monitored_users"].options @@ -477,7 +469,7 @@ async def test_external_timed_out( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -486,13 +478,13 @@ async def test_external_timed_out( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "token_request_timeout" @@ -505,7 +497,7 @@ async def test_callback_view( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -514,7 +506,7 @@ async def test_callback_view( result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP client = await hass_client_no_auth() forward_url = f'{config_flow.AUTH_CALLBACK_PATH}?flow_id={result["flow_id"]}' @@ -541,7 +533,7 @@ async def test_manual_config( config_flow.DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" assert result["data_schema"] is None hass.config_entries.flow.async_abort(result["flow_id"]) @@ -553,7 +545,7 @@ async def test_manual_config( ) assert result["data_schema"] is not None - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_advanced" with patch("plexauth.PlexAuth.initiate_auth"): @@ -561,7 +553,7 @@ async def test_manual_config( result["flow_id"], user_input={"setup_method": AUTOMATIC_SETUP_STRING} ) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP hass.config_entries.flow.async_abort(result["flow_id"]) # Advanced manual @@ -571,14 +563,14 @@ async def test_manual_config( ) assert result["data_schema"] is not None - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_advanced" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"setup_method": MANUAL_SETUP_STRING} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" MANUAL_SERVER = { @@ -599,7 +591,7 @@ async def test_manual_config( result["flow_id"], user_input=MANUAL_SERVER_NO_HOST_OR_TOKEN ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" assert result["errors"]["base"] == "host_or_token" @@ -611,7 +603,7 @@ async def test_manual_config( result["flow_id"], user_input=MANUAL_SERVER ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" assert result["errors"]["base"] == "ssl_error" @@ -623,7 +615,7 @@ async def test_manual_config( result["flow_id"], user_input=MANUAL_SERVER ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" assert result["errors"]["base"] == "ssl_error" @@ -635,7 +627,7 @@ async def test_manual_config( result["flow_id"], user_input=MANUAL_SERVER ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" assert result["errors"]["base"] == "ssl_error" @@ -647,15 +639,12 @@ async def test_manual_config( ) await hass.async_block_till_done() - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - - assert result["title"] == mock_plex_server.url_in_use - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name - assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier - assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use + assert result["title"] == "http://1.2.3.4:32400" + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" + assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == "http://1.2.3.4:32400" assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -673,14 +662,14 @@ async def test_manual_config_with_token( context={"source": SOURCE_USER, "show_advanced_options": True}, ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_advanced" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"setup_method": MANUAL_SETUP_STRING} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "manual_setup" with patch( @@ -690,15 +679,13 @@ async def test_manual_config_with_token( result["flow_id"], user_input={CONF_TOKEN: MOCK_TOKEN} ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY - server_id = result["data"][CONF_SERVER_IDENTIFIER] - mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] - mock_url = mock_plex_server.url_in_use + mock_url = "https://1-2-3-4.123456789001234567890.plex.direct:32400" assert result["title"] == mock_url - assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name - assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier + assert result["data"][CONF_SERVER] == "Plex Server 1" + assert result["data"][CONF_SERVER_IDENTIFIER] == "unique_id_123" assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_url assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN @@ -708,26 +695,6 @@ async def test_manual_config_with_token( await hass.async_block_till_done() -async def test_setup_with_limited_credentials( - hass: HomeAssistant, entry, setup_plex_server -) -> None: - """Test setup with a user with limited permissions.""" - with patch( - "plexapi.server.PlexServer.systemAccounts", - side_effect=plexapi.exceptions.Unauthorized, - ) as mock_accounts: - mock_plex_server = await setup_plex_server() - - assert mock_accounts.called - - plex_server = hass.data[DOMAIN][SERVERS][mock_plex_server.machine_identifier] - assert len(plex_server.accounts) == 0 - assert plex_server.owner is None - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is ConfigEntryState.LOADED - - async def test_integration_discovery(hass: HomeAssistant) -> None: """Test integration self-discovery.""" mock_gdm = MockGDM() @@ -781,13 +748,13 @@ async def test_trigger_reauth( "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" ): result = await hass.config_entries.flow.async_configure(flow_id, user_input={}) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert result["flow_id"] == flow_id @@ -795,8 +762,8 @@ async def test_trigger_reauth( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED - assert entry.data[CONF_SERVER] == mock_plex_server.friendly_name - assert entry.data[CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier + assert entry.data[CONF_SERVER] == "Plex Server 1" + assert entry.data[CONF_SERVER_IDENTIFIER] == "unique_id_123" assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" @@ -837,13 +804,13 @@ async def test_trigger_reauth_multiple_servers_available( "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" ): result = await hass.config_entries.flow.async_configure(flow_id, user_input={}) - assert result["type"] == "external" + assert result["type"] == FlowResultType.EXTERNAL_STEP result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "external_done" + assert result["type"] == FlowResultType.EXTERNAL_STEP_DONE result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["flow_id"] == flow_id assert result["reason"] == "reauth_successful" @@ -851,8 +818,8 @@ async def test_trigger_reauth_multiple_servers_available( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED - assert entry.data[CONF_SERVER] == mock_plex_server.friendly_name - assert entry.data[CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier + assert entry.data[CONF_SERVER] == "Plex Server 1" + assert entry.data[CONF_SERVER_IDENTIFIER] == "unique_id_123" assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" @@ -862,7 +829,7 @@ async def test_client_request_missing(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -884,7 +851,7 @@ async def test_client_header_issues( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" with patch("plexauth.PlexAuth.initiate_auth"), patch( diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 4ef3e3dc8131..cdfa409237fc 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -314,3 +314,25 @@ async def test_scan_clients_schedule(hass: HomeAssistant, setup_plex_server) -> await hass.async_block_till_done() assert mock_scan_clients.called + + +async def test_setup_with_limited_credentials( + hass: HomeAssistant, entry, setup_plex_server +) -> None: + """Test setup with a user with limited permissions.""" + with patch( + "plexapi.server.PlexServer.systemAccounts", + side_effect=plexapi.exceptions.Unauthorized, + ) as mock_accounts: + mock_plex_server = await setup_plex_server() + + assert mock_accounts.called + + plex_server = hass.data[const.DOMAIN][const.SERVERS][ + mock_plex_server.machine_identifier + ] + assert len(plex_server.accounts) == 0 + assert plex_server.owner is None + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED From 62b0603b764dd926a956250a503cb3e0c3ae90bb Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 5 Mar 2023 01:03:36 -0500 Subject: [PATCH 0216/1058] Bump pyvizio to 0.1.60 (#89160) --- homeassistant/components/vizio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 572aba0829fc..9b63ef17a9ca 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["pyvizio"], "quality_scale": "platinum", - "requirements": ["pyvizio==0.1.57"], + "requirements": ["pyvizio==0.1.60"], "zeroconf": ["_viziocast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 96bd1d20f0b9..000aa752b42c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2174,7 +2174,7 @@ pyversasense==0.0.6 pyvesync==2.1.1 # homeassistant.components.vizio -pyvizio==0.1.57 +pyvizio==0.1.60 # homeassistant.components.velux pyvlx==0.2.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 896ded1d1ca3..e02ee6a6c3c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1549,7 +1549,7 @@ pyvera==0.3.13 pyvesync==2.1.1 # homeassistant.components.vizio -pyvizio==0.1.57 +pyvizio==0.1.60 # homeassistant.components.volumio pyvolumio==0.1.5 From 6dc99d2ad8c960b674b3086838e248fcf5f7ee95 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 5 Mar 2023 12:40:12 +0100 Subject: [PATCH 0217/1058] Bump `brother` and `pysnmplib` backend libraries (#89100) * Bump brother and pysnmplib * Fix tests --- homeassistant/components/brother/manifest.json | 2 +- homeassistant/components/snmp/manifest.json | 2 +- homeassistant/components/snmp/sensor.py | 4 ++-- homeassistant/components/snmp/switch.py | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/snmp/test_sensor.py | 5 +---- 7 files changed, 10 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index bd5d877b4f36..cba44b68c6ac 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==2.2.0"], + "requirements": ["brother==2.3.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 8194b3f96ce9..324a1e493661 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], - "requirements": ["pysnmplib==5.0.20"] + "requirements": ["pysnmplib==5.0.21"] } diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index c20e5fe6e367..fc8068fb532a 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -145,7 +145,7 @@ async def async_setup_platform( ContextData(), ] get_result = await getCmd(*request_args, ObjectType(ObjectIdentity(baseoid))) - errindication, _, _, _ = await get_result + errindication, _, _, _ = get_result if errindication and not accept_errors: _LOGGER.error("Please check the details in the configuration file") @@ -207,7 +207,7 @@ class SnmpData: get_result = await getCmd( *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) ) - errindication, errstatus, errindex, restable = await get_result + errindication, errstatus, errindex, restable = get_result if errindication and not self._accept_errors: _LOGGER.error("SNMP error: %s", errindication) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 4699aaefd772..d0fe393d5508 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -261,7 +261,7 @@ class SnmpSwitch(SwitchEntity): get_result = await getCmd( *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) ) - errindication, errstatus, errindex, restable = await get_result + errindication, errstatus, errindex, restable = get_result if errindication: _LOGGER.error("SNMP error: %s", errindication) diff --git a/requirements_all.txt b/requirements_all.txt index 000aa752b42c..7e6008ce147c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -480,7 +480,7 @@ boto3==1.20.24 broadlink==0.18.3 # homeassistant.components.brother -brother==2.2.0 +brother==2.3.0 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 @@ -1982,7 +1982,7 @@ pysmarty==0.8 pysml==0.0.8 # homeassistant.components.snmp -pysnmplib==5.0.20 +pysnmplib==5.0.21 # homeassistant.components.snooz pysnooz==0.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e02ee6a6c3c5..d74867edbfe0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -393,7 +393,7 @@ boschshcpy==0.2.35 broadlink==0.18.3 # homeassistant.components.brother -brother==2.2.0 +brother==2.3.0 # homeassistant.components.brunt brunt==1.2.0 @@ -1435,7 +1435,7 @@ pysmartthings==0.7.6 pysml==0.0.8 # homeassistant.components.snmp -pysnmplib==5.0.20 +pysnmplib==5.0.21 # homeassistant.components.snooz pysnooz==0.8.3 diff --git a/tests/components/snmp/test_sensor.py b/tests/components/snmp/test_sensor.py index b15cc4bfa61e..d6637946da80 100644 --- a/tests/components/snmp/test_sensor.py +++ b/tests/components/snmp/test_sensor.py @@ -1,6 +1,5 @@ """SNMP sensor tests.""" -import asyncio from unittest.mock import MagicMock, Mock, patch import pytest @@ -16,11 +15,9 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = MagicMock() mock_data.prettyPrint = Mock(return_value="13.5") - future = asyncio.get_event_loop().create_future() - future.set_result((None, None, None, [[mock_data]])) with patch( "homeassistant.components.snmp.sensor.getCmd", - return_value=future, + return_value=(None, None, None, [[mock_data]]), ): yield From 927b43626cc489fa8200d2c8f259011760847341 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Mar 2023 01:44:45 -1000 Subject: [PATCH 0218/1058] Bump aiodiscover to 1.4.14 (#89174) --- homeassistant/components/dhcp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 0765c762b86a..a5ee449dda62 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.13"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.4.14"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd2950c9641e..b6d109fbfb64 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ PyJWT==2.5.0 PyNaCl==1.5.0 -aiodiscover==1.4.13 +aiodiscover==1.4.14 aiohttp==3.8.4 aiohttp_cors==0.7.0 astral==2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 7e6008ce147c..59663e13613c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -137,7 +137,7 @@ aiobafi6==0.7.3 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.13 +aiodiscover==1.4.14 # homeassistant.components.dnsip # homeassistant.components.minecraft_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d74867edbfe0..e59fe698e1ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -124,7 +124,7 @@ aiobafi6==0.7.3 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.13 +aiodiscover==1.4.14 # homeassistant.components.dnsip # homeassistant.components.minecraft_server From 11681f3f315a50d1fe5a46e65c080a1df809597e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Mar 2023 01:46:02 -1000 Subject: [PATCH 0219/1058] Pass a helpful name when creating common asyncio tasks in core (#89171) --- homeassistant/bootstrap.py | 4 +- .../homeassistant/triggers/homeassistant.py | 2 +- .../homeassistant/triggers/numeric_state.py | 2 +- .../homeassistant/triggers/state.py | 2 +- .../components/homeassistant/triggers/time.py | 2 +- .../homeassistant/triggers/time_pattern.py | 2 +- homeassistant/config_entries.py | 33 +++++++++--- homeassistant/core.py | 34 ++++++++---- homeassistant/helpers/debounce.py | 16 ++++-- homeassistant/helpers/discovery.py | 4 +- homeassistant/helpers/discovery_flow.py | 2 +- homeassistant/helpers/dispatcher.py | 3 +- homeassistant/helpers/entity.py | 9 +++- homeassistant/helpers/entity_component.py | 5 +- homeassistant/helpers/entity_platform.py | 2 + homeassistant/helpers/event.py | 54 +++++++++++++------ homeassistant/helpers/restore_state.py | 2 +- homeassistant/helpers/storage.py | 4 +- homeassistant/helpers/trigger.py | 2 +- homeassistant/helpers/update_coordinator.py | 9 +++- homeassistant/setup.py | 4 +- tests/common.py | 4 +- tests/test_core.py | 29 +++++++++- 23 files changed, 169 insertions(+), 61 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index e87ee1ae2820..29772e865afd 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -508,7 +508,9 @@ async def async_setup_multi_components( ) -> None: """Set up multiple domains. Log on failure.""" futures = { - domain: hass.async_create_task(async_setup_component(hass, domain, config)) + domain: hass.async_create_task( + async_setup_component(hass, domain, config), f"setup component {domain}" + ) for domain in domains } await asyncio.wait(futures.values()) diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index e3dc93a9788a..51686e54c55c 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -27,7 +27,7 @@ async def async_attach_trigger( """Listen for events based on configuration.""" trigger_data = trigger_info["trigger_data"] event = config.get(CONF_EVENT) - job = HassJob(action) + job = HassJob(action, f"homeassistant trigger {trigger_info}") if event == EVENT_SHUTDOWN: diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 53d3fb1217f2..d822cd523fc6 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -100,7 +100,7 @@ async def async_attach_trigger( armed_entities = set() period: dict = {} attribute = config.get(CONF_ATTRIBUTE) - job = HassJob(action) + job = HassJob(action, f"numeric state trigger {trigger_info}") trigger_data = trigger_info["trigger_data"] _variables = trigger_info["variables"] or {} diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 25622e0a3c63..7fc780d7976c 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -123,7 +123,7 @@ async def async_attach_trigger( unsub_track_same = {} period: dict[str, timedelta] = {} attribute = config.get(CONF_ATTRIBUTE) - job = HassJob(action) + job = HassJob(action, f"state trigger {trigger_info}") trigger_data = trigger_info["trigger_data"] _variables = trigger_info["variables"] or {} diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index f5473d66a5b2..a29cb5ff6da8 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -49,7 +49,7 @@ async def async_attach_trigger( trigger_data = trigger_info["trigger_data"] entities: dict[str, CALLBACK_TYPE] = {} removes = [] - job = HassJob(action) + job = HassJob(action, f"time trigger {trigger_info}") @callback def time_automation_listener(description, now, *, entity_id=None): diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py index 2a5022bebf3e..63f9b18cf9b1 100644 --- a/homeassistant/components/homeassistant/triggers/time_pattern.py +++ b/homeassistant/components/homeassistant/triggers/time_pattern.py @@ -66,7 +66,7 @@ async def async_attach_trigger( hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) seconds = config.get(CONF_SECONDS) - job = HassJob(action) + job = HassJob(action, f"time pattern trigger {trigger_info}") # If larger units are specified, default the smaller units to zero if minutes is None and hours is not None: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 94f2bab75acb..29788a678ad7 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -729,7 +729,8 @@ class ConfigEntry: } | (context or {}), data=self.data | (data or {}), - ) + ), + f"config entry reauth {self.title} {self.domain} {self.entry_id}", ) @callback @@ -746,7 +747,10 @@ class ConfigEntry: @callback def async_create_task( - self, hass: HomeAssistant, target: Coroutine[Any, Any, _R] + self, + hass: HomeAssistant, + target: Coroutine[Any, Any, _R], + name: str | None = None, ) -> asyncio.Task[_R]: """Create a task from within the eventloop. @@ -754,7 +758,9 @@ class ConfigEntry: target: target to call. """ - task = hass.async_create_task(target) + task = hass.async_create_task( + target, f"{name} {self.title} {self.domain} {self.entry_id}" + ) self._tasks.add(task) task.add_done_callback(self._tasks.remove) @@ -824,7 +830,10 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): init_done: asyncio.Future[None] = asyncio.Future() self._pending_import_flows.setdefault(handler, {})[flow_id] = init_done - task = asyncio.create_task(self._async_init(flow_id, handler, context, data)) + task = asyncio.create_task( + self._async_init(flow_id, handler, context, data), + name=f"config entry flow {handler} {flow_id}", + ) self._initialize_tasks.setdefault(handler, []).append(task) try: @@ -1112,7 +1121,8 @@ class ConfigEntries: entry.domain, context={"source": SOURCE_UNIGNORE}, data={"unique_id": entry.unique_id}, - ) + ), + f"config entry unignore {entry.title} {entry.domain} {entry.unique_id}", ) self._async_dispatch(ConfigEntryChange.REMOVED, entry) @@ -1337,7 +1347,10 @@ class ConfigEntries: for listener_ref in entry.update_listeners: if (listener := listener_ref()) is not None: - self.hass.async_create_task(listener(self.hass, entry)) + self.hass.async_create_task( + listener(self.hass, entry), + f"config entry update listener {entry.title} {entry.domain} {entry.domain}", + ) self._async_schedule_save() self._async_dispatch(ConfigEntryChange.UPDATED, entry) @@ -1367,7 +1380,10 @@ class ConfigEntries: error_if_core=False, ) for platform in platforms: - self.hass.async_create_task(self.async_forward_entry_setup(entry, platform)) + self.hass.async_create_task( + self.async_forward_entry_setup(entry, platform), + f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", + ) async def async_forward_entry_setups( self, entry: ConfigEntry, platforms: Iterable[Platform | str] @@ -1549,7 +1565,8 @@ class ConfigFlow(data_entry_flow.FlowHandler): continue if should_reload: self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) + 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) diff --git a/homeassistant/core.py b/homeassistant/core.py index b2525e2f096e..8650b9a9e31b 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -217,16 +217,17 @@ class HassJob(Generic[_P, _R_co]): we run the job. """ - __slots__ = ("job_type", "target") + __slots__ = ("job_type", "target", "name") - def __init__(self, target: Callable[_P, _R_co]) -> None: + def __init__(self, target: Callable[_P, _R_co], name: str | None = None) -> None: """Create a job object.""" self.target = target + self.name = name self.job_type = _get_hassjob_callable_job_type(target) def __repr__(self) -> str: """Return the job.""" - return f"" + return f"" def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: @@ -488,7 +489,7 @@ class HomeAssistant: hassjob.target = cast( Callable[..., Coroutine[Any, Any, _R]], hassjob.target ) - task = self.loop.create_task(hassjob.target(*args)) + task = self.loop.create_task(hassjob.target(*args), name=hassjob.name) elif hassjob.job_type == HassJobType.Callback: if TYPE_CHECKING: hassjob.target = cast(Callable[..., _R], hassjob.target) @@ -512,7 +513,9 @@ class HomeAssistant: self.loop.call_soon_threadsafe(self.async_create_task, target) @callback - def async_create_task(self, target: Coroutine[Any, Any, _R]) -> asyncio.Task[_R]: + def async_create_task( + self, target: Coroutine[Any, Any, _R], name: str | None = None + ) -> asyncio.Task[_R]: """Create a task from within the eventloop. This method must be run in the event loop. If you are using this in your @@ -520,7 +523,7 @@ class HomeAssistant: target: target to call. """ - task = self.loop.create_task(target) + task = self.loop.create_task(target, name=name) self._tasks.add(task) task.add_done_callback(self._tasks.remove) return task @@ -1037,7 +1040,10 @@ class EventBus: if run_immediately and not is_callback(listener): raise HomeAssistantError(f"Event listener {listener} is not a callback") return self._async_listen_filterable_job( - event_type, _FilterableJob(HassJob(listener), event_filter, run_immediately) + event_type, + _FilterableJob( + HassJob(listener, "listen {event_type}"), event_filter, run_immediately + ), ) @callback @@ -1111,7 +1117,11 @@ class EventBus: _onetime_listener, listener, ("__name__", "__qualname__", "__module__"), [] ) - filterable_job = _FilterableJob(HassJob(_onetime_listener), None, False) + filterable_job = _FilterableJob( + HassJob(_onetime_listener, "onetime listen {event_type} {listener}"), + None, + False, + ) return self._async_listen_filterable_job(event_type, filterable_job) @@ -1558,16 +1568,18 @@ class StateMachine: class Service: """Representation of a callable service.""" - __slots__ = ["job", "schema"] + __slots__ = ["job", "schema", "domain", "service"] def __init__( self, func: Callable[[ServiceCall], Coroutine[Any, Any, None] | None], schema: vol.Schema | None, + domain: str, + service: str, context: Context | None = None, ) -> None: """Initialize a service.""" - self.job = HassJob(func) + self.job = HassJob(func, f"service {domain}.{service}") self.schema = schema @@ -1659,7 +1671,7 @@ class ServiceRegistry: """ domain = domain.lower() service = service.lower() - service_obj = Service(service_func, schema) + service_obj = Service(service_func, schema, domain, service) if domain in self._services: self._services[domain][service] = service_obj diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 2fbdefd7ec04..b4a4cde0c1fa 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -38,7 +38,11 @@ class Debouncer(Generic[_R_co]): self._execute_at_end_of_timer: bool = False self._execute_lock = asyncio.Lock() self._job: HassJob[[], _R_co] | None = ( - None if function is None else HassJob(function) + None + if function is None + else HassJob( + function, f"debouncer cooldown={cooldown}, immediate={immediate}" + ) ) @property @@ -51,7 +55,10 @@ class Debouncer(Generic[_R_co]): """Update the function being wrapped by the Debouncer.""" self._function = function if self._job is None or function != self._job.target: - self._job = HassJob(function) + self._job = HassJob( + function, + f"debouncer cooldown={self.cooldown}, immediate={self.immediate}", + ) async def async_call(self) -> None: """Call the function.""" @@ -126,5 +133,8 @@ class Debouncer(Generic[_R_co]): """Schedule a timer.""" self._timer_task = self.hass.loop.call_later( self.cooldown, - lambda: self.hass.async_create_task(self._handle_timer_finish()), + lambda: self.hass.async_create_task( + self._handle_timer_finish(), + f"debouncer {self._job} finish cooldown={self.cooldown}, immediate={self.immediate}", + ), ) diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 375c3b09c2ea..824b1de701a5 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -44,7 +44,7 @@ def async_listen( Service can be a string or a list/tuple. """ - job = core.HassJob(callback) + job = core.HassJob(callback, f"discovery listener {service}") async def discovery_event_listener(discovered: DiscoveryDict) -> None: """Listen for discovery events.""" @@ -103,7 +103,7 @@ def async_listen_platform( This method must be run in the event loop. """ service = EVENT_LOAD_PLATFORM.format(component) - job = core.HassJob(callback) + job = core.HassJob(callback, f"platform loaded {component}") async def discovery_platform_listener(discovered: DiscoveryDict) -> None: """Listen for platform discovery events.""" diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index f7e78e82fb4d..bd5ee4942d0e 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -29,7 +29,7 @@ def async_create_flow( if not dispatcher or dispatcher.started: if init_coro := _async_init_flow(hass, domain, context, data): - hass.async_create_task(init_coro) + hass.async_create_task(init_coro, f"discovery flow {domain} {context}") return return dispatcher.async_create(domain, context, data) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index c7ad4fb1adf1..60aab156144f 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -75,7 +75,8 @@ def _generate_job( signal, args, ), - ) + ), + f"dispatcher {signal}", ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index c4dfd7e9c5b5..9c1bbe5b209e 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -702,7 +702,10 @@ class Entity(ABC): been executed, the intermediate state transitions will be missed. """ if force_refresh: - self.hass.async_create_task(self.async_update_ha_state(force_refresh)) + self.hass.async_create_task( + self.async_update_ha_state(force_refresh), + f"Entity schedule update ha state {self.entity_id}", + ) else: self.async_write_ha_state() @@ -722,7 +725,9 @@ class Entity(ABC): try: task: asyncio.Future[None] if hasattr(self, "async_update"): - task = self.hass.async_create_task(self.async_update()) + task = self.hass.async_create_task( + self.async_update(), f"Entity async update {self.entity_id}" + ) elif hasattr(self, "update"): task = self.hass.async_add_executor_job(self.update) else: diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 874c37ffd9f6..0c43dddec604 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -131,7 +131,10 @@ class EntityComponent(Generic[_EntityT]): # Look in config for Domain, Domain 2, Domain 3 etc and load them for p_type, p_config in config_per_platform(config, self.domain): if p_type is not None: - self.hass.async_create_task(self.async_setup_platform(p_type, p_config)) + self.hass.async_create_task( + self.async_setup_platform(p_type, p_config), + f"EntityComponent setup platform {p_type} {self.domain}", + ) # Generic discovery listener for loading platform dynamically # Refer to: homeassistant.helpers.discovery.async_load_platform() diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index c002915a4dfe..e085f819e3c8 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -375,6 +375,7 @@ class EntityPlatform: """Schedule adding entities for a single platform async.""" task = self.hass.async_create_task( self.async_add_entities(new_entities, update_before_add=update_before_add), + f"EntityPlatform async_add_entities {self.domain}.{self.platform_name}", ) if not self._setup_complete: @@ -389,6 +390,7 @@ class EntityPlatform: task = self.config_entry.async_create_task( self.hass, self.async_add_entities(new_entities, update_before_add=update_before_add), + f"EntityPlatform async_add_entities_for_entry {self.domain}.{self.platform_name}", ) if not self._setup_complete: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 7490206f0373..a924d9cb88bb 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -176,7 +176,7 @@ def async_track_state_change( else: entity_ids = tuple(entity_id.lower() for entity_id in entity_ids) - job = HassJob(action) + job = HassJob(action, f"track state change {entity_ids} {from_state} {to_state}") @callback def state_change_filter(event: Event) -> bool: @@ -296,7 +296,7 @@ def _async_track_state_change_event( event_filter=_async_state_change_filter, ) - job = HassJob(action) + job = HassJob(action, f"track state change event {entity_ids}") for entity_id in entity_ids: entity_callbacks.setdefault(entity_id, []).append(job) @@ -393,7 +393,7 @@ def async_track_entity_registry_updated_event( event_filter=_async_entity_registry_updated_filter, ) - job = HassJob(action) + job = HassJob(action, f"track entity registry updated event {entity_ids}") for entity_id in entity_ids: entity_callbacks.setdefault(entity_id, []).append(job) @@ -476,7 +476,7 @@ def _async_track_state_added_domain( event_filter=_async_state_change_filter, ) - job = HassJob(action) + job = HassJob(action, f"track state added domain event {domains}") for domain in domains: domain_callbacks.setdefault(domain, []).append(job) @@ -530,7 +530,7 @@ def async_track_state_removed_domain( event_filter=_async_state_change_filter, ) - job = HassJob(action) + job = HassJob(action, f"track state removed domain event {domains}") for domain in domains: domain_callbacks.setdefault(domain, []).append(job) @@ -569,7 +569,9 @@ class _TrackStateChangeFiltered: """Handle removal / refresh of tracker init.""" self.hass = hass self._action = action - self._action_as_hassjob = HassJob(action) + self._action_as_hassjob = HassJob( + action, f"track state change filtered {track_states}" + ) self._listeners: dict[str, Callable[[], None]] = {} self._last_track_states: TrackStates = track_states @@ -764,7 +766,7 @@ def async_track_template( Callable to unregister the listener. """ - job = HassJob(action) + job = HassJob(action, f"track template {template}") @callback def _template_changed_listener( @@ -821,7 +823,7 @@ class TrackTemplateResultInfo: ) -> None: """Handle removal / refresh of tracker init.""" self.hass = hass - self._job = HassJob(action) + self._job = HassJob(action, f"track template result {track_templates}") for track_template_ in track_templates: track_template_.template.hass = hass @@ -1215,7 +1217,7 @@ def async_track_same_state( async_remove_state_for_cancel: CALLBACK_TYPE | None = None async_remove_state_for_listener: CALLBACK_TYPE | None = None - job = HassJob(action) + job = HassJob(action, f"track same state {period} {entity_ids}") @callback def clear_listener() -> None: @@ -1277,7 +1279,11 @@ def async_track_point_in_time( point_in_time: datetime, ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in time.""" - job = action if isinstance(action, HassJob) else HassJob(action) + job = ( + action + if isinstance(action, HassJob) + else HassJob(action, f"track point in time {point_in_time}") + ) @callback def utc_converter(utc_now: datetime) -> None: @@ -1324,7 +1330,11 @@ def async_track_point_in_utc_time( hass.async_run_hass_job(job, utc_point_in_time) - job = action if isinstance(action, HassJob) else HassJob(action) + 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 = hass.loop.call_later(delta, run_action, job) @@ -1357,7 +1367,11 @@ def async_call_later( """Call the action.""" hass.async_run_hass_job(job, time_tracker_utcnow()) - job = action if isinstance(action, HassJob) else HassJob(action) + job = ( + action + if isinstance(action, HassJob) + else HassJob(action, f"call_later {delay}") + ) cancel_callback = hass.loop.call_later(delay, run_action, job) @callback @@ -1383,7 +1397,7 @@ def async_track_time_interval( remove: CALLBACK_TYPE interval_listener_job: HassJob[[datetime], None] - job = HassJob(action) + job = HassJob(action, f"track time interval {interval}") def next_interval() -> datetime: """Return the next interval.""" @@ -1400,7 +1414,9 @@ def async_track_time_interval( ) hass.async_run_hass_job(job, now) - interval_listener_job = HassJob(interval_listener) + interval_listener_job = HassJob( + interval_listener, f"track time interval listener {interval}" + ) remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) def remove_listener() -> None: @@ -1479,7 +1495,9 @@ def async_track_sunrise( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: """Add a listener that will fire a specified offset from sunrise daily.""" - listener = SunListener(hass, HassJob(action), SUN_EVENT_SUNRISE, offset) + listener = SunListener( + hass, HassJob(action, "track sunrise"), SUN_EVENT_SUNRISE, offset + ) listener.async_attach() return listener.async_detach @@ -1493,7 +1511,9 @@ def async_track_sunset( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: """Add a listener that will fire a specified offset from sunset daily.""" - listener = SunListener(hass, HassJob(action), SUN_EVENT_SUNSET, offset) + listener = SunListener( + hass, HassJob(action, "track sunset"), SUN_EVENT_SUNSET, offset + ) listener.async_attach() return listener.async_detach @@ -1526,7 +1546,7 @@ def async_track_utc_time_change( # misalignment we use async_track_time_interval here return async_track_time_interval(hass, action, timedelta(seconds=1)) - job = HassJob(action) + job = HassJob(action, f"track time change {hour}:{minute}:{second} local={local}") matching_seconds = dt_util.parse_time_expression(second, 0, 59) matching_minutes = dt_util.parse_time_expression(minute, 0, 59) matching_hours = dt_util.parse_time_expression(hour, 0, 23) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index d7e30661b38b..0263bd286828 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -212,7 +212,7 @@ class RestoreStateData: # Dump the initial states now. This helps minimize the risk of having # old states loaded by overwriting the last states once Home Assistant # has started and the old states have been read. - self.hass.async_create_task(_async_dump_states()) + self.hass.async_create_task(_async_dump_states(), "RestoreStateData dump") # Dump states periodically cancel_interval = async_track_time_interval( diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 19e028af9004..bd9b01cd6a6c 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -115,7 +115,9 @@ class Store(Generic[_T]): the second call will wait and return the result of the first call. """ if self._load_task is None: - self._load_task = self.hass.async_create_task(self._async_load()) + self._load_task = self.hass.async_create_task( + self._async_load(), f"Storage load {self.key}" + ) return await self._load_task diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 314c0e8939ec..e2963b15ab4f 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -169,7 +169,7 @@ class PluggableAction: if not entry.actions and not entry.plugs: del reg[key] - job = HassJob(action) + job = HassJob(action, f"trigger {trigger} {variables}") entry.actions[_remove] = (job, variables) _update() diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 9f9b1a30ba6e..e8ca1a1f91d3 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -87,7 +87,14 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): ) self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} - self._job = HassJob(self._handle_refresh_interval) + job_name = "DataUpdateCoordinator" + type_name = type(self).__name__ + if type_name != job_name: + job_name += f" {type_name}" + job_name += f" {name}" + if entry := self.config_entry: + job_name += f" {entry.title} {entry.domain} {entry.entry_id}" + self._job = HassJob(self._handle_refresh_interval, job_name) self._unsub_refresh: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None self.last_update_success = True diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 2377f47d7e91..df5d8257083d 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -93,7 +93,7 @@ async def async_setup_component( return await setup_tasks[domain] task = setup_tasks[domain] = hass.async_create_task( - _async_setup_component(hass, domain, config) + _async_setup_component(hass, domain, config), f"setup component {domain}" ) try: @@ -426,7 +426,7 @@ def _async_when_setup( _LOGGER.exception("Error handling when_setup callback for %s", component) if component in hass.config.components: - hass.async_create_task(when_setup()) + hass.async_create_task(when_setup(), f"when setup {component}") return listeners: list[CALLBACK_TYPE] = [] diff --git a/tests/common.py b/tests/common.py index c25ad0cca61a..8b9a9c240179 100644 --- a/tests/common.py +++ b/tests/common.py @@ -210,14 +210,14 @@ async def async_test_home_assistant(event_loop, load_registries=True): return orig_async_add_executor_job(target, *args) - def async_create_task(coroutine): + def async_create_task(coroutine, name=None): """Create task.""" if isinstance(coroutine, Mock) and not isinstance(coroutine, AsyncMock): fut = asyncio.Future() fut.set_result(None) return fut - return orig_async_create_task(coroutine) + return orig_async_create_task(coroutine, name) hass.async_add_job = async_add_job hass.async_add_executor_job = async_add_executor_job diff --git a/tests/test_core.py b/tests/test_core.py index f627475270f4..6d67376b4188 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -65,7 +65,7 @@ def test_split_entity_id() -> None: def test_async_add_hass_job_schedule_callback() -> None: - """Test that we schedule coroutines and add jobs to the job pool.""" + """Test that we schedule callbacks and add jobs to the job pool.""" hass = MagicMock() job = MagicMock() @@ -75,6 +75,19 @@ def test_async_add_hass_job_schedule_callback() -> None: assert len(hass.add_job.mock_calls) == 0 +def test_async_add_hass_job_coro_named(hass) -> None: + """Test that we schedule coroutines and add jobs to the job pool with a name.""" + + async def mycoro(): + pass + + job = ha.HassJob(mycoro, "named coro") + assert "named coro" in str(job) + assert job.name == "named coro" + task = ha.HomeAssistant.async_add_hass_job(hass, job) + assert "named coro" in str(task) + + def test_async_add_hass_job_schedule_partial_callback() -> None: """Test that we schedule partial coros and add jobs to the job pool.""" hass = MagicMock() @@ -141,6 +154,20 @@ def test_async_create_task_schedule_coroutine(event_loop) -> None: assert len(hass.add_job.mock_calls) == 0 +def test_async_create_task_schedule_coroutine_with_name(event_loop) -> None: + """Test that we schedule coroutines and add jobs to the job pool with a name.""" + hass = MagicMock(loop=MagicMock(wraps=event_loop)) + + async def job(): + pass + + task = ha.HomeAssistant.async_create_task(hass, job(), "named task") + assert len(hass.loop.call_soon.mock_calls) == 0 + assert len(hass.loop.create_task.mock_calls) == 1 + assert len(hass.add_job.mock_calls) == 0 + assert "named task" in str(task) + + def test_async_run_hass_job_calls_callback() -> None: """Test that the callback annotation is respected.""" hass = MagicMock() From afd37c8a0a77c6b5c197b2031d620953672fde4c Mon Sep 17 00:00:00 2001 From: gjong Date: Sun, 5 Mar 2023 12:49:02 +0100 Subject: [PATCH 0220/1058] Bump youless api version to v1.0.1 (#89117) --- homeassistant/components/youless/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json index 02cca76c4aa1..7c0ea36a060a 100644 --- a/homeassistant/components/youless/manifest.json +++ b/homeassistant/components/youless/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/youless", "iot_class": "local_polling", "loggers": ["youless_api"], - "requirements": ["youless-api==0.16"] + "requirements": ["youless-api==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 59663e13613c..7be57484c049 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2688,7 +2688,7 @@ yeelightsunflower==0.0.10 yolink-api==0.2.8 # homeassistant.components.youless -youless-api==0.16 +youless-api==1.0.1 # homeassistant.components.media_extractor youtube_dl==2021.12.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e59fe698e1ab..e7e13fdc4c38 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1916,7 +1916,7 @@ yeelight==0.7.10 yolink-api==0.2.8 # homeassistant.components.youless -youless-api==0.16 +youless-api==1.0.1 # homeassistant.components.zamg zamg==0.2.2 From 85618fd3cd3f3b82e1ac053c438742229fd56e56 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Sun, 5 Mar 2023 12:54:39 +0100 Subject: [PATCH 0221/1058] Bump overkiz dependency to 1.7.7 (#89163) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 6ba7db46dd33..caa4f6c3868f 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.7.6"], + "requirements": ["pyoverkiz==1.7.7"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 7be57484c049..b49ebeca2a47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1857,7 +1857,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.6 +pyoverkiz==1.7.7 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7e13fdc4c38..dc58276e3272 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1343,7 +1343,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.6 +pyoverkiz==1.7.7 # homeassistant.components.openweathermap pyowm==3.2.0 From 39db0ef17353692ef4059540e4b731e094b94e9c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 13:01:10 +0100 Subject: [PATCH 0222/1058] Add Reolink button platform (#88687) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 8 +- homeassistant/components/reolink/button.py | 136 +++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/reolink/button.py diff --git a/.coveragerc b/.coveragerc index 48fb4044572a..e9c34e4d8fd9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -977,6 +977,7 @@ omit = homeassistant/components/remote_rpi_gpio/* homeassistant/components/reolink/__init__.py homeassistant/components/reolink/binary_sensor.py + homeassistant/components/reolink/button.py homeassistant/components/reolink/camera.py homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 2faa89232afa..7de112395600 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -23,7 +23,13 @@ from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.NUMBER, Platform.UPDATE] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CAMERA, + Platform.NUMBER, + Platform.UPDATE, +] DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) FIRMWARE_UPDATE_INTERVAL = timedelta(hours=12) diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py new file mode 100644 index 000000000000..528eb8c74052 --- /dev/null +++ b/homeassistant/components/reolink/button.py @@ -0,0 +1,136 @@ +"""Component providing support for Reolink button entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import GuardEnum, Host, PtzEnum + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity + + +@dataclass +class ReolinkButtonEntityDescriptionMixin: + """Mixin values for Reolink button entities.""" + + method: Callable[[Host, int], Any] + + +@dataclass +class ReolinkButtonEntityDescription( + ButtonEntityDescription, ReolinkButtonEntityDescriptionMixin +): + """A class that describes button entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +BUTTON_ENTITIES = ( + ReolinkButtonEntityDescription( + key="ptz_stop", + name="PTZ stop", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value), + ), + ReolinkButtonEntityDescription( + key="ptz_left", + name="PTZ left", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value), + ), + ReolinkButtonEntityDescription( + key="ptz_right", + name="PTZ right", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value), + ), + ReolinkButtonEntityDescription( + key="ptz_up", + name="PTZ up", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value), + ), + ReolinkButtonEntityDescription( + key="ptz_down", + name="PTZ down", + icon="mdi:pan", + supported=lambda api, ch: api.supported(ch, "pan_tilt"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value), + ), + ReolinkButtonEntityDescription( + key="ptz_calibrate", + name="PTZ calibrate", + icon="mdi:pan", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ptz_callibrate"), + method=lambda api, ch: api.ptz_callibrate(ch), + ), + ReolinkButtonEntityDescription( + key="guard_go_to", + name="Guard go to", + icon="mdi:crosshairs-gps", + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.goto.value), + ), + ReolinkButtonEntityDescription( + key="guard_set", + name="Guard set current position", + icon="mdi:crosshairs-gps", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.set.value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink button entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkButtonEntity(reolink_data, channel, entity_description) + for entity_description in BUTTON_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) + + +class ReolinkButtonEntity(ReolinkCoordinatorEntity, ButtonEntity): + """Base button entity class for Reolink IP cameras.""" + + entity_description: ReolinkButtonEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkButtonEntityDescription, + ) -> None: + """Initialize Reolink button entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + async def async_press(self) -> None: + """Execute the button action.""" + await self.entity_description.method(self._host.api, self._channel) From 3f7a58786f9b8576a112c80428918d86054361e8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 13:34:07 +0100 Subject: [PATCH 0223/1058] Bump reolink-aio to 0.5.3 (#89145) --- 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 62b2b5a038e5..978b38daeae4 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.5.1"] + "requirements": ["reolink-aio==0.5.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b49ebeca2a47..2e92100dadf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.1 +reolink-aio==0.5.3 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc58276e3272..09cc93bdcd34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1591,7 +1591,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.1 +reolink-aio==0.5.3 # homeassistant.components.python_script restrictedpython==6.0 From 08b3945d9b9d83093ff2ded4e8acc7039c2f9248 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Sun, 5 Mar 2023 12:35:32 +0000 Subject: [PATCH 0224/1058] Bump pyroon to 0.1.4 (#89124) --- homeassistant/components/roon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index f1d26af1909d..4fa527d07694 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roon", "iot_class": "local_push", "loggers": ["roonapi"], - "requirements": ["roonapi==0.1.3"] + "requirements": ["roonapi==0.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2e92100dadf3..ad423c2a3e7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2267,7 +2267,7 @@ rokuecp==0.17.1 roombapy==1.6.5 # homeassistant.components.roon -roonapi==0.1.3 +roonapi==0.1.4 # homeassistant.components.rova rova==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09cc93bdcd34..fa53828960a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1609,7 +1609,7 @@ rokuecp==0.17.1 roombapy==1.6.5 # homeassistant.components.roon -roonapi==0.1.3 +roonapi==0.1.4 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 From 3614114a8fd7cf7da07c89444c61ca2e634e28ff Mon Sep 17 00:00:00 2001 From: Carlos Cristobal <87995947+sw-carlos-cristobal@users.noreply.github.com> Date: Sun, 5 Mar 2023 05:36:25 -0700 Subject: [PATCH 0225/1058] Revert "Replace Fitbit weight SensorStateClass measurement with total" (#89126) --- homeassistant/components/fitbit/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 8a80ac610f72..d746e63ca522 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -220,7 +220,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Weight", unit_type="weight", icon="mdi:human", - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WEIGHT, ), FitbitSensorEntityDescription( From 189c6121008b1e73d48c1772a39293822831ce2a Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Sun, 5 Mar 2023 13:36:56 +0100 Subject: [PATCH 0226/1058] Add support for Livisi PSSO, ISS and ISS2 switch devices (#89140) --- homeassistant/components/livisi/const.py | 2 +- homeassistant/components/livisi/switch.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index 684510cf7e32..98e0b7816c63 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -14,7 +14,7 @@ DEVICE_POLLING_DELAY: Final = 60 LIVISI_STATE_CHANGE: Final = "livisi_state_change" LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" -PSS_DEVICE_TYPE: Final = "PSS" +SWITCH_DEVICE_TYPES: Final = ["ISS", "ISS2", "PSS", "PSSO"] VRCC_DEVICE_TYPE: Final = "VRCC" MAX_TEMPERATURE: Final = 30.0 diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index f5201ab8faac..1a5789ea24e9 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, PSS_DEVICE_TYPE +from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES from .coordinator import LivisiDataUpdateCoordinator from .entity import LivisiEntity @@ -30,7 +30,7 @@ async def async_setup_entry( entities: list[SwitchEntity] = [] for device in shc_devices: if ( - device["type"] == PSS_DEVICE_TYPE + device["type"] in SWITCH_DEVICE_TYPES and device["id"] not in coordinator.devices ): livisi_switch: SwitchEntity = LivisiSwitch( From cf369ff1a5965dd86cb02770fc624a7b148b4428 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 5 Mar 2023 13:40:10 +0100 Subject: [PATCH 0227/1058] Unpin pandas for Python 3.11 (#89033) --- homeassistant/package_constraints.txt | 3 ++- script/gen_requirements_all.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b6d109fbfb64..d72bea19837e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -137,7 +137,8 @@ pubnub!=6.4.0 iso4217!=1.10.20220401 # Pandas 1.4.4 has issues with wheels om armhf + Py3.10 -pandas==1.4.3 +# Limit this to Python 3.10, to be able to install Python 3.11 wheels for now +pandas==1.4.3;python_version<'3.11' # Matplotlib 3.6.2 has issues building wheels on armhf/armv7 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 9f08f0b62890..cd53635d966a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -144,7 +144,8 @@ pubnub!=6.4.0 iso4217!=1.10.20220401 # Pandas 1.4.4 has issues with wheels om armhf + Py3.10 -pandas==1.4.3 +# Limit this to Python 3.10, to be able to install Python 3.11 wheels for now +pandas==1.4.3;python_version<'3.11' # Matplotlib 3.6.2 has issues building wheels on armhf/armv7 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) From b51dadbfe68a42b04e89b465840781cd0fed3d42 Mon Sep 17 00:00:00 2001 From: Geoff Date: Mon, 6 Mar 2023 01:49:51 +1300 Subject: [PATCH 0228/1058] Update link to opencv in image_process log message (#89008) --- homeassistant/components/opencv/image_processing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/opencv/image_processing.py b/homeassistant/components/opencv/image_processing.py index 7c3a881edf8f..41738100cab9 100644 --- a/homeassistant/components/opencv/image_processing.py +++ b/homeassistant/components/opencv/image_processing.py @@ -104,7 +104,7 @@ def setup_platform( if not CV2_IMPORTED: _LOGGER.error( "No OpenCV library found! Install or compile for your system " - "following instructions here: http://opencv.org/releases.html" + "following instructions here: https://opencv.org/?s=releases" ) return From cc6721c06bcc373e0b25883bc9cece5d0fbdbfef Mon Sep 17 00:00:00 2001 From: Andrew Westrope Date: Sun, 5 Mar 2023 12:51:02 +0000 Subject: [PATCH 0229/1058] Check type key of zone exists in geniushub (#86798) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/geniushub/climate.py | 2 +- homeassistant/components/geniushub/switch.py | 2 +- homeassistant/components/geniushub/water_heater.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index 21ef28093609..c2b32582cef5 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -41,7 +41,7 @@ async def async_setup_platform( [ GeniusClimateZone(broker, z) for z in broker.client.zone_objs - if z.data["type"] in GH_ZONES + if z.data.get("type") in GH_ZONES ] ) diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py index cf29d0ea8028..79ba418d509f 100644 --- a/homeassistant/components/geniushub/switch.py +++ b/homeassistant/components/geniushub/switch.py @@ -42,7 +42,7 @@ async def async_setup_platform( [ GeniusSwitch(broker, z) for z in broker.client.zone_objs - if z.data["type"] == GH_ON_OFF_ZONE + if z.data.get("type") == GH_ON_OFF_ZONE ] ) diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py index ea8b1a439616..f8cf7288e577 100644 --- a/homeassistant/components/geniushub/water_heater.py +++ b/homeassistant/components/geniushub/water_heater.py @@ -48,7 +48,7 @@ async def async_setup_platform( [ GeniusWaterHeater(broker, z) for z in broker.client.zone_objs - if z.data["type"] in GH_HEATERS + if z.data.get("type") in GH_HEATERS ] ) From 680f3c27a57ce7783114bd9cb1573ed8f53ed545 Mon Sep 17 00:00:00 2001 From: Ben Morton Date: Sun, 5 Mar 2023 13:02:38 +0000 Subject: [PATCH 0230/1058] Add support for Spotify podcasts (#87671) --- .../components/spotify/media_player.py | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 1145686efe76..51dd645ec021 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -104,7 +104,6 @@ class SpotifyMediaPlayer(MediaPlayerEntity): _attr_has_entity_name = True _attr_icon = "mdi:spotify" - _attr_media_content_type = MediaType.MUSIC _attr_media_image_remotely_accessible = False def __init__( @@ -161,6 +160,15 @@ class SpotifyMediaPlayer(MediaPlayerEntity): item = self._currently_playing.get("item") or {} return item.get("uri") + @property + def media_content_type(self) -> str | None: + """Return the media type.""" + if not self._currently_playing: + return None + item = self._currently_playing.get("item") or {} + is_episode = item.get("type") == MediaType.EPISODE + return MediaType.PODCAST if is_episode else MediaType.MUSIC + @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" @@ -191,13 +199,20 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @property def media_image_url(self) -> str | None: """Return the media image URL.""" - if ( - not self._currently_playing - or self._currently_playing.get("item") is None - or not self._currently_playing["item"]["album"]["images"] - ): + if not self._currently_playing or self._currently_playing.get("item") is None: return None - return fetch_image_url(self._currently_playing["item"]["album"]) + + item = self._currently_playing["item"] + if item["type"] == MediaType.EPISODE: + if item["images"]: + return fetch_image_url(item) + if item["show"]["images"]: + return fetch_image_url(item["show"]) + return None + + if not item["album"]["images"]: + return None + return fetch_image_url(item["album"]) @property def media_title(self) -> str | None: @@ -212,16 +227,24 @@ class SpotifyMediaPlayer(MediaPlayerEntity): """Return the media artist.""" if not self._currently_playing or self._currently_playing.get("item") is None: return None - return ", ".join( - artist["name"] for artist in self._currently_playing["item"]["artists"] - ) + + item = self._currently_playing["item"] + if item["type"] == MediaType.EPISODE: + return item["show"]["publisher"] + + return ", ".join(artist["name"] for artist in item["artists"]) @property def media_album_name(self) -> str | None: """Return the media album.""" if not self._currently_playing or self._currently_playing.get("item") is None: return None - return self._currently_playing["item"]["album"]["name"] + + item = self._currently_playing["item"] + if item["type"] == MediaType.EPISODE: + return item["show"]["name"] + + return item["album"]["name"] @property def media_track(self) -> int | None: @@ -359,7 +382,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): ).result() self.data.client.set_auth(auth=self.data.session.token["access_token"]) - current = self.data.client.current_playback() + current = self.data.client.current_playback( + additional_types=[MediaType.EPISODE] + ) self._currently_playing = current or {} context = self._currently_playing.get("context") From 2e1f6cad96db2a4eca20c95fe36c1673b9739982 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sun, 5 Mar 2023 08:00:16 -0600 Subject: [PATCH 0231/1058] Detect newly connected clients in jellyfin (#89168) --- homeassistant/components/jellyfin/__init__.py | 4 +- .../components/jellyfin/coordinator.py | 11 +- .../components/jellyfin/media_player.py | 27 +- .../fixtures/sessions-new-client.json | 4846 +++++++++++++++++ .../components/jellyfin/test_media_player.py | 23 + 5 files changed, 4899 insertions(+), 12 deletions(-) create mode 100644 tests/components/jellyfin/fixtures/sessions-new-client.json diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 39085317a54e..565c106f6aee 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -36,7 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: server_info: dict[str, Any] = connect_result["Servers"][0] coordinators: dict[str, JellyfinDataUpdateCoordinator[Any]] = { - "sessions": SessionsDataUpdateCoordinator(hass, client, server_info, user_id), + "sessions": SessionsDataUpdateCoordinator( + hass, client, server_info, entry.data[CONF_CLIENT_DEVICE_ID], user_id + ), } for coordinator in coordinators.values(): diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index b7563dcd8627..3d5b150f39f9 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, LOGGER +from .const import DOMAIN, LOGGER, USER_APP_NAME JellyfinDataT = TypeVar( "JellyfinDataT", @@ -29,6 +29,7 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[JellyfinDataT], ABC): hass: HomeAssistant, api_client: JellyfinClient, system_info: dict[str, Any], + client_device_id: str, user_id: str, ) -> None: """Initialize the coordinator.""" @@ -42,8 +43,11 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[JellyfinDataT], ABC): self.server_id: str = system_info["Id"] self.server_name: str = system_info["Name"] self.server_version: str | None = system_info.get("Version") + self.client_device_id: str = client_device_id self.user_id: str = user_id + self.session_ids: set[str] = set() + async def _async_update_data(self) -> JellyfinDataT: """Get the latest data from Jellyfin.""" return await self._fetch_data() @@ -65,7 +69,10 @@ class SessionsDataUpdateCoordinator( ) sessions_by_id: dict[str, dict[str, Any]] = { - session["Id"]: session for session in sessions + session["Id"]: session + for session in sessions + if session["DeviceId"] != self.client_device_id + and session["Client"] != USER_APP_NAME } return sessions_by_id diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 60fae2caac71..3b3c8fbdf52f 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -19,7 +19,7 @@ from homeassistant.util.dt import parse_datetime from .browse_media import build_item_response, build_root_response from .client_wrapper import get_artwork_url -from .const import CONTENT_TYPE_MAP, DOMAIN, USER_APP_NAME +from .const import CONTENT_TYPE_MAP, DOMAIN, LOGGER from .coordinator import JellyfinDataUpdateCoordinator from .entity import JellyfinEntity from .models import JellyfinData @@ -34,14 +34,23 @@ async def async_setup_entry( jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id] coordinator = jellyfin_data.coordinators["sessions"] - async_add_entities( - ( - JellyfinMediaPlayer(coordinator, session_id, session_data) - for session_id, session_data in coordinator.data.items() - if session_data["DeviceId"] != jellyfin_data.client_device_id - and session_data["Client"] != USER_APP_NAME - ), - ) + @callback + def handle_coordinator_update() -> None: + """Add media player per session.""" + entities: list[MediaPlayerEntity] = [] + for session_id, session_data in coordinator.data.items(): + if session_id not in coordinator.session_ids: + entity: MediaPlayerEntity = JellyfinMediaPlayer( + coordinator, session_id, session_data + ) + LOGGER.debug("Creating media player for session: %s", session_id) + coordinator.session_ids.add(session_id) + entities.append(entity) + async_add_entities(entities) + + handle_coordinator_update() + + entry.async_on_unload(coordinator.async_add_listener(handle_coordinator_update)) class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): diff --git a/tests/components/jellyfin/fixtures/sessions-new-client.json b/tests/components/jellyfin/fixtures/sessions-new-client.json new file mode 100644 index 000000000000..ff8ab8885ae9 --- /dev/null +++ b/tests/components/jellyfin/fixtures/sessions-new-client.json @@ -0,0 +1,4846 @@ +[ + { + "PlayState": { + "PositionTicks": 100000000, + "CanSeek": true, + "IsPaused": true, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID", + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 600000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 3, + "IndexNumberEnd": 0, + "ParentIndexNumber": 1, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "PARENT-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "DEVICE-UUID", + "ApplicationVersion": "1.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 230000000, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 55, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["VolumeSet", "Mute"], + "SupportsMediaControl": true, + "SupportsContentUploading": true, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": true, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID-TWO", + "UserId": "USER-UUID-TWO", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE-TWO", + "DeviceType": "string", + "NowPlayingItem": { + "Name": "MOVIE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 2000000000, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "", + "Type": "Movie", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "SERIES", + "SeriesId": "SERIES-UUID", + "SeasonId": "SEASON-UUID", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "SEASON", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "Backdrop": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "HASS", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "FullNowPlayingItem": { + "Size": 0, + "Container": "string", + "IsHD": true, + "IsShortcut": true, + "ShortcutPath": "string", + "Width": 0, + "Height": 0, + "ExtraIds": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "DateLastSaved": "2019-08-24T14:15:22Z", + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "SupportsExternalTransfer": true + }, + "NowViewingItem": { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + }, + "DeviceId": "DEVICE-UUID-TWO", + "ApplicationVersion": "1.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 0, + "CanSeek": true, + "IsPaused": false, + "IsMuted": true, + "VolumeLevel": 0, + "AudioStreamIndex": 0, + "SubtitleStreamIndex": 0, + "MediaSourceId": "string", + "PlayMethod": "Transcode", + "RepeatMode": "RepeatNone", + "LiveStreamId": "string" + }, + "AdditionalUsers": [ + { + "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", + "UserName": "string" + } + ], + "Capabilities": { + "PlayableMediaTypes": ["Video"], + "SupportedCommands": ["MoveUp"], + "SupportsMediaControl": false, + "SupportsContentUploading": false, + "MessageCallbackUrl": "string", + "SupportsPersistentIdentifier": false, + "SupportsSync": true, + "DeviceProfile": { + "Name": "string", + "Id": "string", + "Identification": { + "FriendlyName": "string", + "ModelNumber": "string", + "SerialNumber": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelUrl": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "Headers": [ + { + "Name": "string", + "Value": "string", + "Match": "Equals" + } + ] + }, + "FriendlyName": "string", + "Manufacturer": "string", + "ManufacturerUrl": "string", + "ModelName": "string", + "ModelDescription": "string", + "ModelNumber": "string", + "ModelUrl": "string", + "SerialNumber": "string", + "EnableAlbumArtInDidl": false, + "EnableSingleAlbumArtLimit": false, + "EnableSingleSubtitleLimit": false, + "SupportedMediaTypes": "string", + "UserId": "string", + "AlbumArtPn": "string", + "MaxAlbumArtWidth": 0, + "MaxAlbumArtHeight": 0, + "MaxIconWidth": 0, + "MaxIconHeight": 0, + "MaxStreamingBitrate": 0, + "MaxStaticBitrate": 0, + "MusicStreamingTranscodingBitrate": 0, + "MaxStaticMusicBitrate": 0, + "SonyAggregationFlags": "string", + "ProtocolInfo": "string", + "TimelineOffsetSeconds": 0, + "RequiresPlainVideoItems": false, + "RequiresPlainFolders": false, + "EnableMSMediaReceiverRegistrar": false, + "IgnoreTranscodeByteRangeRequests": false, + "XmlRootAttributes": [ + { + "Name": "string", + "Value": "string" + } + ], + "DirectPlayProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio" + } + ], + "TranscodingProfiles": [ + { + "Container": "string", + "Type": "Audio", + "VideoCodec": "string", + "AudioCodec": "string", + "Protocol": "string", + "EstimateContentLength": false, + "EnableMpegtsM2TsMode": false, + "TranscodeSeekInfo": "Auto", + "CopyTimestamps": false, + "Context": "Streaming", + "EnableSubtitlesInManifest": false, + "MaxAudioChannels": "string", + "MinSegments": 0, + "SegmentLength": 0, + "BreakOnNonKeyFrames": false, + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "ContainerProfiles": [ + { + "Type": "Audio", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Container": "string" + } + ], + "CodecProfiles": [ + { + "Type": "Video", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ], + "Codec": "string", + "Container": "string" + } + ], + "ResponseProfiles": [ + { + "Container": "string", + "AudioCodec": "string", + "VideoCodec": "string", + "Type": "Audio", + "OrgPn": "string", + "MimeType": "string", + "Conditions": [ + { + "Condition": "Equals", + "Property": "AudioChannels", + "Value": "string", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "string", + "Method": "Encode", + "DidlMode": "string", + "Language": "string", + "Container": "string" + } + ] + }, + "AppStoreUrl": "string", + "IconUrl": "string" + }, + "RemoteEndPoint": "string", + "PlayableMediaTypes": ["Video"], + "Id": "SESSION-UUID-THREE", + "UserId": "USER-UUID", + "UserName": "string", + "Client": "Jellyfin for Developers", + "LastActivityDate": "2019-08-24T14:15:22Z", + "LastPlaybackCheckIn": "2019-08-24T14:15:22Z", + "DeviceName": "JELLYFIN-DEVICE-THREE", + "DeviceType": "string", + "DeviceId": "DEVICE-UUID-THREE", + "ApplicationVersion": "2.0.0", + "TranscodingInfo": { + "AudioCodec": "string", + "VideoCodec": "string", + "Container": "string", + "IsVideoDirect": true, + "IsAudioDirect": true, + "Bitrate": 0, + "Framerate": 0, + "CompletionPercentage": 0, + "Width": 0, + "Height": 0, + "AudioChannels": 0, + "HardwareAccelerationType": "AMF", + "TranscodeReasons": "ContainerNotSupported" + }, + "IsActive": true, + "SupportsMediaControl": false, + "SupportsRemoteControl": false, + "NowPlayingQueue": [ + { + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "PlaylistItemId": "string" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "string", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": true, + "ParentId": "c54e2d15-b5eb-48b7-9b04-53f376904b1e", + "Type": "AggregateFolder", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "HasCustomDeviceName": true, + "PlaylistItemId": "string", + "ServerId": "SERVER-UUID", + "UserPrimaryImageTag": "string", + "SupportedCommands": ["MoveUp"] + }, + { + "PlayState": { + "PositionTicks": 220246970, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 100, + "MediaSourceId": "a744119f757f88858f95aab1628708c4", + "PlayMethod": "DirectPlay", + "RepeatMode": "RepeatNone" + }, + "AdditionalUsers": [], + "Capabilities": { + "PlayableMediaTypes": ["Audio", "Video"], + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ], + "SupportsMediaControl": true, + "SupportsContentUploading": false, + "SupportsPersistentIdentifier": false, + "SupportsSync": false + }, + "RemoteEndPoint": "192.168.1.254", + "PlayableMediaTypes": ["Audio", "Video"], + "Id": "SESSION-UUID-FOUR", + "UserId": "USER-UUID-TWO", + "UserName": "USER", + "Client": "Jellyfin Android", + "LastActivityDate": "2022-10-19T03:20:20.1214274Z", + "LastPlaybackCheckIn": "2022-10-19T03:20:18.0973168Z", + "DeviceName": "JELLYFIN DEVICE FOUR", + "NowPlayingItem": { + "Name": "MUSIC FILE", + "ServerId": "SERVER-UUID", + "Id": "MUSIC-UUID", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "ExternalUrls": [], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "IndexNumber": 1, + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "Artists": ["Contributing Artist"], + "ArtistItems": [ + { + "Name": "Contributing Artist", + "Id": "1d864900526d9a9513b489f1cc28f8ca" + } + ], + "Album": "ALBUM", + "AlbumId": "ALBUM-UUID", + "AlbumArtist": "Album Artist", + "AlbumArtists": [ + { "Name": "Album Artist", "Id": "9a65b2c222ddb34e51f5cae360fad3a1" } + ], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio" + }, + "FullNowPlayingItem": { + "Size": 2356453, + "IsHD": false, + "IsShortcut": false, + "Width": 0, + "Height": 0, + "ExtraIds": [], + "DateLastSaved": "2022-10-19T03:10:11.9765475Z", + "RemoteTrailers": [], + "SupportsExternalTransfer": false + }, + "DeviceId": "DEVICE-UUID-FOUR", + "ApplicationVersion": "2.4.4", + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "a744119f757f88858f95aab1628708c4", + "PlaylistItemId": "playlistItem2" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "ServerId": "e1012aa74e1b40c8ac50f3af79e9e83f", + "Id": "a744119f757f88858f95aab1628708c4", + "Etag": "64ed7b4ce1127c5d41e685de30090383", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "CanDelete": true, + "CanDownload": true, + "SortName": "string", + "ExternalUrls": [], + "MediaSources": [ + { + "Protocol": "File", + "Id": "a744119f757f88858f95aab1628708c4", + "Path": "string", + "Type": "Default", + "Container": "mp3", + "Size": 2356453, + "Name": "string", + "IsRemote": false, + "ETag": "83b0e0ece75386b479a2c3a09f71d695", + "RunTimeTicks": 736391552, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 256000, + "RequiredHttpHeaders": {} + } + ], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "61bba315f137702baa296a1c417faada", + "Tags": [], + "Artists": [], + "ArtistItems": [], + "AlbumArtists": [], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio", + "LockedFields": [], + "LockData": false + } + ], + "HasCustomDeviceName": false, + "PlaylistItemId": "playlistItem2", + "ServerId": "SERVER-UUID", + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ] + }, + { + "PlayState": { + "PositionTicks": 220246970, + "CanSeek": true, + "IsPaused": false, + "IsMuted": false, + "VolumeLevel": 100, + "MediaSourceId": "a744119f757f88858f95aab1628708c4", + "PlayMethod": "DirectPlay", + "RepeatMode": "RepeatNone" + }, + "AdditionalUsers": [], + "Capabilities": { + "PlayableMediaTypes": ["Audio", "Video"], + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ], + "SupportsMediaControl": true, + "SupportsContentUploading": false, + "SupportsPersistentIdentifier": false, + "SupportsSync": false + }, + "RemoteEndPoint": "192.168.1.253", + "PlayableMediaTypes": ["Audio", "Video"], + "Id": "SESSION-UUID-FIVE", + "UserId": "USER-UUID-THREE", + "UserName": "USER", + "Client": "Jellyfin Android", + "LastActivityDate": "2022-10-19T03:20:20.1214274Z", + "LastPlaybackCheckIn": "2022-10-19T03:20:18.0973168Z", + "DeviceName": "JELLYFIN DEVICE FIVE", + "NowPlayingItem": { + "Name": "MUSIC FILE", + "ServerId": "SERVER-UUID", + "Id": "MUSIC-UUID", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "ExternalUrls": [], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "IndexNumber": 1, + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "Artists": ["Contributing Artist"], + "ArtistItems": [ + { + "Name": "Contributing Artist", + "Id": "1d864900526d9a9513b489f1cc28f8ca" + } + ], + "Album": "ALBUM", + "AlbumId": "ALBUM-UUID", + "AlbumArtist": "Album Artist", + "AlbumArtists": [ + { "Name": "Album Artist", "Id": "9a65b2c222ddb34e51f5cae360fad3a1" } + ], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio" + }, + "FullNowPlayingItem": { + "Size": 2356453, + "IsHD": false, + "IsShortcut": false, + "Width": 0, + "Height": 0, + "ExtraIds": [], + "DateLastSaved": "2022-10-19T03:10:11.9765475Z", + "RemoteTrailers": [], + "SupportsExternalTransfer": false + }, + "DeviceId": "DEVICE-UUID-FIVE", + "ApplicationVersion": "2.4.4", + "IsActive": true, + "SupportsMediaControl": true, + "SupportsRemoteControl": true, + "NowPlayingQueue": [ + { + "Id": "a744119f757f88858f95aab1628708c4", + "PlaylistItemId": "playlistItem2" + } + ], + "NowPlayingQueueFullItems": [ + { + "Name": "string", + "ServerId": "e1012aa74e1b40c8ac50f3af79e9e83f", + "Id": "a744119f757f88858f95aab1628708c4", + "Etag": "64ed7b4ce1127c5d41e685de30090383", + "DateCreated": "2022-10-19T03:09:11.392057Z", + "CanDelete": true, + "CanDownload": true, + "SortName": "string", + "ExternalUrls": [], + "MediaSources": [ + { + "Protocol": "File", + "Id": "a744119f757f88858f95aab1628708c4", + "Path": "string", + "Type": "Default", + "Container": "mp3", + "Size": 2356453, + "Name": "string", + "IsRemote": false, + "ETag": "83b0e0ece75386b479a2c3a09f71d695", + "RunTimeTicks": 736391552, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 256000, + "RequiredHttpHeaders": {} + } + ], + "Path": "string", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 736391552, + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "4c0343ed1bbcda094178076230051b7e", + "Type": "Audio", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "61bba315f137702baa296a1c417faada", + "Tags": [], + "Artists": [], + "ArtistItems": [], + "AlbumArtists": [], + "MediaStreams": [ + { + "Codec": "mp3", + "TimeBase": "1/14112000", + "DisplayTitle": "MP3 - Stereo", + "IsInterlaced": false, + "ChannelLayout": "stereo", + "BitRate": 256000, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "Type": "Audio", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "LocationType": "FileSystem", + "MediaType": "Audio", + "LockedFields": [], + "LockData": false + } + ], + "HasCustomDeviceName": false, + "PlaylistItemId": "playlistItem2", + "ServerId": "SERVER-UUID", + "SupportedCommands": [ + "MoveUp", + "MoveDown", + "MoveLeft", + "MoveRight", + "PageUp", + "PageDown", + "PreviousLetter", + "NextLetter", + "ToggleOsd", + "ToggleContextMenu", + "Select", + "Back", + "SendKey", + "SendString", + "GoHome", + "GoToSettings", + "VolumeUp", + "VolumeDown", + "Mute", + "Unmute", + "ToggleMute", + "SetVolume", + "SetAudioStreamIndex", + "SetSubtitleStreamIndex", + "DisplayContent", + "GoToSearch", + "DisplayMessage", + "SetRepeatMode", + "SetShuffleQueue", + "ChannelUp", + "ChannelDown", + "PlayMediaSource", + "PlayTrailers" + ] + } +] diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 80a2bf3eae57..64ed41ffdfa8 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -33,6 +33,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow +from . import async_load_json_fixture + from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -353,3 +355,24 @@ async def test_browse_media( response["error"]["message"] == "Media not found: collection / COLLECTION-UUID-404" ) + + +async def test_new_client_connected( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin media player reacts to new clients connecting.""" + mock_api.sessions.return_value = await async_load_json_fixture( + hass, + "sessions-new-client.json", + ) + + assert len(mock_api.sessions.mock_calls) == 1 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(mock_api.sessions.mock_calls) == 2 + + state = hass.states.get("media_player.jellyfin_device_five") + assert state From 7b54061ab75e9f891e653e93ce609c25624406d2 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Sun, 5 Mar 2023 14:43:52 +0000 Subject: [PATCH 0232/1058] Add repeat to roon media player (#88851) --- homeassistant/components/roon/media_player.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 09ecc3cec9fb..d87c6f31371c 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -12,6 +12,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + RepeatMode, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME @@ -35,6 +36,16 @@ SERVICE_TRANSFER = "transfer" ATTR_TRANSFER = "transfer_id" +REPEAT_MODE_MAPPING_TO_HA = { + "loop": RepeatMode.ALL, + "disabled": RepeatMode.OFF, + "loop_one": RepeatMode.ONE, +} + +REPEAT_MODE_MAPPING_TO_ROON = { + value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() +} + async def async_setup_entry( hass: HomeAssistant, @@ -84,6 +95,7 @@ class RoonDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.SHUFFLE_SET | MediaPlayerEntityFeature.SEEK | MediaPlayerEntityFeature.TURN_ON @@ -262,6 +274,9 @@ class RoonDevice(MediaPlayerEntity): self._attr_unique_id = self.player_data["dev_id"] self._zone_id = self.player_data["zone_id"] self._output_id = self.player_data["output_id"] + self._attr_repeat = REPEAT_MODE_MAPPING_TO_HA.get( + self.player_data["settings"]["loop"] + ) self._attr_shuffle = self.player_data["settings"]["shuffle"] self._attr_name = self.player_data["display_name"] @@ -331,7 +346,7 @@ class RoonDevice(MediaPlayerEntity): def set_volume_level(self, volume: float) -> None: """Send new volume_level to device.""" - volume = int(volume * 100) + volume = volume * 100 self._server.roonapi.set_volume_percent(self.output_id, volume) def mute_volume(self, mute=True): @@ -373,6 +388,12 @@ class RoonDevice(MediaPlayerEntity): """Set shuffle state.""" self._server.roonapi.shuffle(self.output_id, shuffle) + def set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + if repeat not in REPEAT_MODE_MAPPING_TO_ROON: + raise ValueError(f"Unsupported repeat mode: {repeat}") + self._server.roonapi.repeat(self.output_id, REPEAT_MODE_MAPPING_TO_ROON[repeat]) + def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: """Send the play_media command to the media player.""" From 2fc2c2efbeb80d92713423ec473fd9b8d96b6357 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 5 Mar 2023 17:05:32 +0100 Subject: [PATCH 0233/1058] Remove deprecated Moon YAML configuration (#89161) * Remove deprecated Moon YAML configuration * Restore old title defaults --- homeassistant/components/moon/config_flow.py | 10 +---- homeassistant/components/moon/sensor.py | 44 ++------------------ tests/components/moon/test_config_flow.py | 25 +---------- tests/components/moon/test_init.py | 30 ------------- 4 files changed, 6 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/moon/config_flow.py b/homeassistant/components/moon/config_flow.py index abdd60c7b658..08b2a4995f14 100644 --- a/homeassistant/components/moon/config_flow.py +++ b/homeassistant/components/moon/config_flow.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DOMAIN @@ -23,13 +22,6 @@ class MoonConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") if user_input is not None: - return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), - data={}, - ) + 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/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py index c244f1614718..f8e1cd24abea 100644 --- a/homeassistant/components/moon/sensor.py +++ b/homeassistant/components/moon/sensor.py @@ -2,25 +2,16 @@ from __future__ import annotations from astral import moon -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import 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 import homeassistant.util.dt as dt_util -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN STATE_FIRST_QUARTER = "first_quarter" STATE_FULL_MOON = "full_moon" @@ -42,35 +33,6 @@ MOON_ICONS = { STATE_WAXING_GIBBOUS: "mdi:moon-waxing-gibbous", } -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string} -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Moon sensor.""" - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - 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/moon/test_config_flow.py b/tests/components/moon/test_config_flow.py index 2ef01b4f8907..e7ee1cceefde 100644 --- a/tests/components/moon/test_config_flow.py +++ b/tests/components/moon/test_config_flow.py @@ -1,11 +1,8 @@ """Tests for the Moon config flow.""" from unittest.mock import MagicMock -import pytest - from homeassistant.components.moon.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_NAME +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -34,34 +31,16 @@ async def test_full_user_flow( assert result2.get("data") == {} -@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) async def test_single_instance_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - source: str, ) -> None: """Test we abort if already setup.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source} + DOMAIN, context={"source": SOURCE_USER} ) assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "single_instance_allowed" - - -async def test_import_flow( - hass: HomeAssistant, - mock_setup_entry: MagicMock, -) -> None: - """Test the import configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_NAME: "My Moon"}, - ) - - assert result.get("type") == FlowResultType.CREATE_ENTRY - assert result.get("title") == "My Moon" - assert result.get("data") == {} diff --git a/tests/components/moon/test_init.py b/tests/components/moon/test_init.py index f0f7e5935458..1b483db33de1 100644 --- a/tests/components/moon/test_init.py +++ b/tests/components/moon/test_init.py @@ -1,12 +1,8 @@ """Tests for the Moon integration.""" -from unittest.mock import AsyncMock from homeassistant.components.moon.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -27,29 +23,3 @@ async def test_load_unload_config_entry( assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_import_config( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, -) -> None: - """Test Moon being set up from config via import.""" - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "platform": DOMAIN, - CONF_NAME: "My Moon", - } - }, - ) - await hass.async_block_till_done() - - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - - entry = config_entries[0] - assert entry.title == "My Moon" - assert entry.unique_id is None - assert entry.data == {} From a9becd8e0ed9926fd2ef50bbb7d8a1f06e349e2b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 5 Mar 2023 17:06:48 +0100 Subject: [PATCH 0234/1058] Raise ValueError on date parsing of MQTT sensor with invalid date format (#89036) * Suppress ValueError on date parsing of MQTT sensor * Simplify, but not update state on invalid payload * Still raise an an invalid date * Make datetime state unknown on invalid format * remove unrelated added new line --- homeassistant/components/mqtt/sensor.py | 5 ++++- tests/components/mqtt/test_sensor.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index df51dd60a15b..934f73695803 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -284,7 +284,10 @@ class MqttSensor(MqttEntity, RestoreSensor): if self.device_class is None: self._attr_native_value = new_value return - if (payload_datetime := dt_util.parse_datetime(new_value)) is None: + try: + if (payload_datetime := dt_util.parse_datetime(new_value)) is None: + raise ValueError + except ValueError: _LOGGER.warning( "Invalid state message '%s' from '%s'", msg.payload, msg.topic ) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 09944b56c046..66836a16ee1b 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -134,6 +134,12 @@ async def test_setting_sensor_value_via_mqtt_message( "2021-11-18T19:25:00+00:00", False, ), + ( + sensor.SensorDeviceClass.TIMESTAMP, + "2021-13-18T35:25:00+00:00", + STATE_UNKNOWN, + True, + ), (sensor.SensorDeviceClass.TIMESTAMP, "invalid", STATE_UNKNOWN, True), ], ) From 84402a9ae0fd3dce38ee0752b73bc612be985920 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 5 Mar 2023 17:07:32 +0100 Subject: [PATCH 0235/1058] Remove deprecated Season YAML configuration (#89166) * Remove deprecated Season YAML configuration * Restore old title defaults --- .../components/season/config_flow.py | 8 +-- homeassistant/components/season/sensor.py | 51 ++----------------- tests/components/season/test_config_flow.py | 32 ++---------- tests/components/season/test_init.py | 33 +----------- 4 files changed, 12 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py index 854c0158439a..39a52e57b10b 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_TYPE from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL @@ -25,7 +25,7 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(user_input[CONF_TYPE]) self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), + title=DEFAULT_NAME, data={CONF_TYPE: user_input[CONF_TYPE]}, ) @@ -42,7 +42,3 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): }, ), ) - - 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/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index a568e51ed9d8..27a46943bb3a 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -4,25 +4,17 @@ from __future__ import annotations from datetime import date, datetime import ephem -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import 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.util.dt import utcnow -from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, VALID_TYPES +from .const import DOMAIN, TYPE_ASTRONOMICAL EQUATOR = "equator" @@ -49,39 +41,6 @@ SEASON_ICONS = { } -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the season sensor platform.""" - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -144,7 +103,7 @@ class SeasonSensorEntity(SensorEntity): self.hemisphere = hemisphere self.type = entry.data[CONF_TYPE] self._attr_device_info = DeviceInfo( - name=entry.title, + name="Season", identifiers={(DOMAIN, entry.entry_id)}, entry_type=DeviceEntryType.SERVICE, ) diff --git a/tests/components/season/test_config_flow.py b/tests/components/season/test_config_flow.py index 2cf9e46b6660..6579bb53a9b8 100644 --- a/tests/components/season/test_config_flow.py +++ b/tests/components/season/test_config_flow.py @@ -1,15 +1,9 @@ """Tests for the Season config flow.""" from unittest.mock import MagicMock -import pytest - -from homeassistant.components.season.const import ( - DOMAIN, - TYPE_ASTRONOMICAL, - TYPE_METEOROLOGICAL, -) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.components.season.const import DOMAIN, TYPE_ASTRONOMICAL +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -38,34 +32,16 @@ async def test_full_user_flow( assert result2.get("data") == {CONF_TYPE: TYPE_ASTRONOMICAL} -@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) async def test_single_instance_allowed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - source: str, ) -> None: """Test we abort if already setup.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source}, data={CONF_TYPE: TYPE_ASTRONOMICAL} + DOMAIN, context={"source": SOURCE_USER}, data={CONF_TYPE: TYPE_ASTRONOMICAL} ) assert result.get("type") == FlowResultType.ABORT assert result.get("reason") == "already_configured" - - -async def test_import_flow( - hass: HomeAssistant, - mock_setup_entry: MagicMock, -) -> None: - """Test the import configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_NAME: "My Seasons", CONF_TYPE: TYPE_METEOROLOGICAL}, - ) - - assert result.get("type") == FlowResultType.CREATE_ENTRY - assert result.get("title") == "My Seasons" - assert result.get("data") == {CONF_TYPE: TYPE_METEOROLOGICAL} diff --git a/tests/components/season/test_init.py b/tests/components/season/test_init.py index 94012ba16ddd..9d9645121600 100644 --- a/tests/components/season/test_init.py +++ b/tests/components/season/test_init.py @@ -1,12 +1,7 @@ """Tests for the Season integration.""" -from unittest.mock import AsyncMock - -from homeassistant.components.season.const import DOMAIN, TYPE_ASTRONOMICAL -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.season.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -27,29 +22,3 @@ async def test_load_unload_config_entry( assert not hass.data.get(DOMAIN) assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_import_config( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, -) -> None: - """Test Season being set up from config via import.""" - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - { - SENSOR_DOMAIN: { - "platform": DOMAIN, - CONF_NAME: "My Season", - } - }, - ) - await hass.async_block_till_done() - - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - - entry = config_entries[0] - assert entry.title == "My Season" - assert entry.unique_id == TYPE_ASTRONOMICAL - assert entry.data == {CONF_TYPE: TYPE_ASTRONOMICAL} From f4cda2dfda93296c3d90bb992068077290812271 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 5 Mar 2023 20:30:42 +0100 Subject: [PATCH 0236/1058] Add device_class and state_class to sql (#85418) --- homeassistant/components/sql/__init__.py | 8 ++++++++ homeassistant/components/sql/sensor.py | 22 +++++++++++++++++++++- tests/components/sql/__init__.py | 8 ++++++++ tests/components/sql/test_sensor.py | 17 +++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index bba49c415f82..c0ec2dfab7ff 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -4,8 +4,14 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + STATE_CLASSES_SCHEMA, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -36,6 +42,8 @@ QUERY_SCHEMA = vol.Schema( vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DB_URL): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, } ) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 5d51087a9ddf..27cf798db385 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -11,9 +11,15 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker from homeassistant.components.recorder import CONF_DB_URL, DEFAULT_DB_FILE, DEFAULT_URL -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -54,6 +60,8 @@ async def async_setup_platform( column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) db_url: str | None = conf.get(CONF_DB_URL) + device_class: SensorDeviceClass | None = conf.get(CONF_DEVICE_CLASS) + state_class: SensorStateClass | None = conf.get(CONF_STATE_CLASS) if value_template is not None: value_template.hass = hass @@ -68,6 +76,8 @@ async def async_setup_platform( unique_id, db_url, True, + device_class, + state_class, async_add_entities, ) @@ -104,6 +114,8 @@ async def async_setup_entry( entry.entry_id, db_url, False, + None, + None, async_add_entities, ) @@ -118,6 +130,8 @@ async def async_setup_sensor( unique_id: str | None, db_url: str | None, yaml: bool, + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | None, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" @@ -163,6 +177,8 @@ async def async_setup_sensor( value_template, unique_id, yaml, + device_class, + state_class, ) ], True, @@ -185,11 +201,15 @@ class SQLSensor(SensorEntity): value_template: Template | None, unique_id: str | None, yaml: bool, + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | None, ) -> None: """Initialize the SQL sensor.""" self._query = query self._attr_name = name if yaml else None self._attr_native_unit_of_measurement = unit + self._attr_device_class = device_class + self._attr_state_class = state_class self._template = value_template self._column_name = column self.sessionmaker = sessmaker diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 1c096dfef6a9..f6cfba01e359 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -4,9 +4,15 @@ from __future__ import annotations from typing import Any from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.components.sql.const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -56,6 +62,8 @@ YAML_CONFIG = { CONF_UNIT_OF_MEASUREMENT: "MiB", CONF_UNIQUE_ID: "unique_id_12345", CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, } } diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index d7a12795ed07..bc3143347b50 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -9,6 +9,7 @@ from sqlalchemy import text as sql_text from sqlalchemy.exc import SQLAlchemyError from homeassistant.components.recorder import Recorder +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sql.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import STATE_UNKNOWN @@ -300,3 +301,19 @@ async def test_invalid_url_setup_from_yaml( assert pattern not in caplog.text for pattern in expected_patterns: assert pattern in caplog.text + + +async def test_attributes_from_yaml_setup( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test attributes from yaml config.""" + + assert await async_setup_component(hass, DOMAIN, YAML_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_value") + + assert state.state == "5" + assert state.attributes["device_class"] == SensorDeviceClass.DATA_RATE + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT + assert state.attributes["unit_of_measurement"] == "MiB" From da100040a55e91339c37f1e41ce4dc52ae2dbcd8 Mon Sep 17 00:00:00 2001 From: Khole Date: Sun, 5 Mar 2023 19:43:33 +0000 Subject: [PATCH 0237/1058] Hive add ability to delete device (#80838) --- homeassistant/components/hive/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 4d309fe68470..76d75e517254 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -122,6 +123,13 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: ) +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return True + + def refresh_system( func: Callable[Concatenate[_HiveEntityT, _P], Awaitable[Any]] ) -> Callable[Concatenate[_HiveEntityT, _P], Coroutine[Any, Any, None]]: From b2c9208dd0b6e2b5ea08012b861110d2677fef7b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 21:00:51 +0100 Subject: [PATCH 0238/1058] Reolink add switch platform (#87943) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 1 + homeassistant/components/reolink/switch.py | 239 +++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 homeassistant/components/reolink/switch.py diff --git a/.coveragerc b/.coveragerc index e9c34e4d8fd9..6ef51578a5ca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -982,6 +982,7 @@ omit = homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py homeassistant/components/reolink/number.py + homeassistant/components/reolink/switch.py homeassistant/components/reolink/update.py homeassistant/components/repetier/__init__.py homeassistant/components/repetier/sensor.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 7de112395600..f6a5e1976441 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.NUMBER, + Platform.SWITCH, Platform.UPDATE, ] DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py new file mode 100644 index 000000000000..64d615548566 --- /dev/null +++ b/homeassistant/components/reolink/switch.py @@ -0,0 +1,239 @@ +"""Component providing support for Reolink switch entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import Host + +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 . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkBaseCoordinatorEntity, ReolinkCoordinatorEntity + + +@dataclass +class ReolinkSwitchEntityDescriptionMixin: + """Mixin values for Reolink switch entities.""" + + value: Callable[[Host, int], bool] + method: Callable[[Host, int, bool], Any] + + +@dataclass +class ReolinkSwitchEntityDescription( + SwitchEntityDescription, ReolinkSwitchEntityDescriptionMixin +): + """A class that describes switch entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +@dataclass +class ReolinkNVRSwitchEntityDescriptionMixin: + """Mixin values for Reolink NVR switch entities.""" + + value: Callable[[Host], bool] + method: Callable[[Host, bool], Any] + + +@dataclass +class ReolinkNVRSwitchEntityDescription( + SwitchEntityDescription, ReolinkNVRSwitchEntityDescriptionMixin +): + """A class that describes NVR switch entities.""" + + supported: Callable[[Host], bool] = lambda api: True + + +SWITCH_ENTITIES = ( + ReolinkSwitchEntityDescription( + key="record_audio", + name="Record audio", + icon="mdi:microphone", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "audio"), + value=lambda api, ch: api.audio_record(ch), + method=lambda api, ch, value: api.set_audio(ch, value), + ), + ReolinkSwitchEntityDescription( + key="siren_on_event", + name="Siren on event", + icon="mdi:alarm-light", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "siren"), + value=lambda api, ch: api.audio_alarm_enabled(ch), + method=lambda api, ch, value: api.set_audio_alarm(ch, value), + ), + ReolinkSwitchEntityDescription( + key="auto_tracking", + name="Auto tracking", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "auto_track"), + value=lambda api, ch: api.auto_track_enabled(ch), + method=lambda api, ch, value: api.set_auto_tracking(ch, value), + ), + ReolinkSwitchEntityDescription( + key="auto_focus", + name="Auto focus", + icon="mdi:focus-field", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "auto_focus"), + value=lambda api, ch: api.autofocus_enabled(ch), + method=lambda api, ch, value: api.set_autofocus(ch, value), + ), + ReolinkSwitchEntityDescription( + key="gaurd_return", + name="Guard return", + icon="mdi:crosshairs-gps", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + value=lambda api, ch: api.ptz_guard_enabled(ch), + method=lambda api, ch, value: api.set_ptz_guard(ch, enable=value), + ), +) + +NVR_SWITCH_ENTITIES = ( + ReolinkNVRSwitchEntityDescription( + key="email", + name="Email on event", + icon="mdi:email", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "email"), + value=lambda api: api.email_enabled(), + method=lambda api, value: api.set_email(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="ftp_upload", + name="FTP upload", + icon="mdi:swap-horizontal", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "ftp"), + value=lambda api: api.ftp_enabled(), + method=lambda api, value: api.set_ftp(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="push_notifications", + name="Push notifications", + icon="mdi:message-badge", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "push"), + value=lambda api: api.push_enabled(), + method=lambda api, value: api.set_push(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="record", + name="Record", + icon="mdi:record-rec", + supported=lambda api: api.supported(None, "recording"), + value=lambda api: api.recording_enabled(), + method=lambda api, value: api.set_recording(None, value), + ), + ReolinkNVRSwitchEntityDescription( + key="buzzer", + name="Buzzer on event", + icon="mdi:room-service", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "buzzer"), + value=lambda api: api.buzzer_enabled(), + method=lambda api, value: api.set_buzzer(None, value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink switch entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ReolinkSwitchEntity | ReolinkNVRSwitchEntity] = [ + ReolinkSwitchEntity(reolink_data, channel, entity_description) + for entity_description in SWITCH_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ] + entities.extend( + [ + ReolinkNVRSwitchEntity(reolink_data, entity_description) + for entity_description in NVR_SWITCH_ENTITIES + if entity_description.supported(reolink_data.host.api) + ] + ) + async_add_entities(entities) + + +class ReolinkSwitchEntity(ReolinkCoordinatorEntity, SwitchEntity): + """Base switch entity class for Reolink IP cameras.""" + + entity_description: ReolinkSwitchEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSwitchEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self.entity_description.value(self._host.api, self._channel) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.method(self._host.api, self._channel, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.method(self._host.api, self._channel, False) + self.async_write_ha_state() + + +class ReolinkNVRSwitchEntity(ReolinkBaseCoordinatorEntity, SwitchEntity): + """Switch entity class for Reolink NVR features.""" + + entity_description: ReolinkNVRSwitchEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkNVRSwitchEntityDescription, + ) -> None: + """Initialize Reolink switch entity.""" + super().__init__(reolink_data) + self.entity_description = entity_description + + self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self.entity_description.value(self._host.api) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.entity_description.method(self._host.api, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.entity_description.method(self._host.api, False) + self.async_write_ha_state() From c792631f152395e079d4d0b382e5b1fc8a6732b0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 21:21:22 +0100 Subject: [PATCH 0239/1058] Add Reolink siren platform (#88217) Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 1 + homeassistant/components/reolink/siren.py | 93 ++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 homeassistant/components/reolink/siren.py diff --git a/.coveragerc b/.coveragerc index 6ef51578a5ca..69cd7bf4cab6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -982,6 +982,7 @@ omit = homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py homeassistant/components/reolink/number.py + homeassistant/components/reolink/siren.py homeassistant/components/reolink/switch.py homeassistant/components/reolink/update.py homeassistant/components/repetier/__init__.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index f6a5e1976441..6bc2874285f0 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.NUMBER, + Platform.SIREN, Platform.SWITCH, Platform.UPDATE, ] diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py new file mode 100644 index 000000000000..f2b27dda4d18 --- /dev/null +++ b/homeassistant/components/reolink/siren.py @@ -0,0 +1,93 @@ +"""Component providing support for Reolink siren entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import Host + +from homeassistant.components.siren import ( + ATTR_DURATION, + ATTR_VOLUME_LEVEL, + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity + + +@dataclass +class ReolinkSirenEntityDescription(SirenEntityDescription): + """A class that describes siren entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +SIREN_ENTITIES = ( + ReolinkSirenEntityDescription( + key="siren", + name="Siren", + icon="mdi:alarm-light", + supported=lambda api, ch: api.supported(ch, "siren"), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink siren entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkSirenEntity(reolink_data, channel, entity_description) + for entity_description in SIREN_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) + + +class ReolinkSirenEntity(ReolinkCoordinatorEntity, SirenEntity): + """Base siren entity class for Reolink IP cameras.""" + + _attr_supported_features = ( + SirenEntityFeature.TURN_ON + | SirenEntityFeature.TURN_OFF + | SirenEntityFeature.DURATION + | SirenEntityFeature.VOLUME_SET + ) + entity_description: ReolinkSirenEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSirenEntityDescription, + ) -> None: + """Initialize Reolink siren entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the siren.""" + if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: + await self._host.api.set_volume(self._channel, int(volume * 100)) + duration = kwargs.get(ATTR_DURATION) + await self._host.api.set_siren(self._channel, True, duration) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the siren.""" + await self._host.api.set_siren(self._channel, False, None) From 37ec442ffb175aa32fb9d3a6f92fbe505c0290da Mon Sep 17 00:00:00 2001 From: Tucker Kern Date: Sun, 5 Mar 2023 14:12:30 -0700 Subject: [PATCH 0240/1058] Use title case for Transmission status sensor (#88578 * Use title case for Transmission status sensor * Use localizations for transmission status sensor * Assign device class and options as requested by review. * Don't use title case for entity names --- .../components/transmission/const.py | 6 ++++- .../components/transmission/sensor.py | 27 ++++++++++++------- .../components/transmission/strings.json | 12 +++++++++ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 742ef874a354..94296f53a618 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -1,7 +1,7 @@ """Constants for the Transmission Bittorent Client component.""" DOMAIN = "transmission" -SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle Mode"} +SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle mode"} ORDER_NEWEST_FIRST = "newest_first" ORDER_OLDEST_FIRST = "oldest_first" @@ -44,3 +44,7 @@ DATA_UPDATED = "transmission_data_updated" EVENT_STARTED_TORRENT = "transmission_started_torrent" EVENT_REMOVED_TORRENT = "transmission_removed_torrent" EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent" + +STATE_UP_DOWN = "up_down" +STATE_SEEDING = "seeding" +STATE_DOWNLOADING = "downloading" diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index b1ff20627e15..46d12d6798bf 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -20,6 +20,9 @@ from .const import ( CONF_ORDER, DOMAIN, STATE_ATTR_TORRENT_INFO, + STATE_DOWNLOADING, + STATE_SEEDING, + STATE_UP_DOWN, SUPPORTED_ORDER_MODES, ) @@ -35,14 +38,14 @@ async def async_setup_entry( name = config_entry.data[CONF_NAME] dev = [ - TransmissionSpeedSensor(tm_client, name, "Down Speed", "download"), - TransmissionSpeedSensor(tm_client, name, "Up Speed", "upload"), + TransmissionSpeedSensor(tm_client, name, "Down speed", "download"), + TransmissionSpeedSensor(tm_client, name, "Up speed", "upload"), TransmissionStatusSensor(tm_client, name, "Status"), - TransmissionTorrentsSensor(tm_client, name, "Active Torrents", "active"), - TransmissionTorrentsSensor(tm_client, name, "Paused Torrents", "paused"), - TransmissionTorrentsSensor(tm_client, name, "Total Torrents", "total"), - TransmissionTorrentsSensor(tm_client, name, "Completed Torrents", "completed"), - TransmissionTorrentsSensor(tm_client, name, "Started Torrents", "started"), + TransmissionTorrentsSensor(tm_client, name, "Active torrents", "active"), + TransmissionTorrentsSensor(tm_client, name, "Paused torrents", "paused"), + TransmissionTorrentsSensor(tm_client, name, "Total torrents", "total"), + TransmissionTorrentsSensor(tm_client, name, "Completed torrents", "completed"), + TransmissionTorrentsSensor(tm_client, name, "Started torrents", "started"), ] async_add_entities(dev, True) @@ -123,17 +126,21 @@ class TransmissionSpeedSensor(TransmissionSensor): class TransmissionStatusSensor(TransmissionSensor): """Representation of a Transmission status sensor.""" + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = [STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING] + _attr_translation_key = "transmission_status" + def update(self) -> None: """Get the latest data from Transmission and updates the state.""" if data := self._tm_client.api.data: upload = data.uploadSpeed download = data.downloadSpeed if upload > 0 and download > 0: - self._state = "Up/Down" + self._state = STATE_UP_DOWN elif upload > 0 and download == 0: - self._state = "Seeding" + self._state = STATE_SEEDING elif upload == 0 and download > 0: - self._state = "Downloading" + self._state = STATE_DOWNLOADING else: self._state = STATE_IDLE else: diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 2cf9fafff483..ed1b2f185a2d 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -40,5 +40,17 @@ } } } + }, + "entity": { + "sensor": { + "transmission_status": { + "state": { + "idle": "Idle", + "up_down": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading" + } + } + } } } From d9fc932253072af1c39e00b684133c98105a55de Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 5 Mar 2023 22:19:40 +0100 Subject: [PATCH 0241/1058] Fix Tuya Python 3.11 compatibility issue (#89189) --- homeassistant/components/tuya/light.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 1a2d0c526d01..ffc00e6f92ca 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -1,7 +1,7 @@ """Support for the Tuya lights.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import json from typing import Any, cast @@ -59,7 +59,9 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data: DPCode | tuple[DPCode, ...] | None = None color_mode: DPCode | None = None color_temp: DPCode | tuple[DPCode, ...] | None = None - default_color_type: ColorTypeData = DEFAULT_COLOR_TYPE_DATA + default_color_type: ColorTypeData = field( + default_factory=lambda: DEFAULT_COLOR_TYPE_DATA + ) LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { From 497e3cf744c7625f2c17141d426ea4b6d2d3f9f0 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sun, 5 Mar 2023 23:35:48 +0100 Subject: [PATCH 0242/1058] Bump bthome to 2.8.0 (#89192) --- 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 f875074a9ff9..da8f719bf70e 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==2.7.0"] + "requirements": ["bthome-ble==2.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ad423c2a3e7c..2bd1992333c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -492,7 +492,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.7.0 +bthome-ble==2.8.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 fa53828960a1..5adbd10f709e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ brother==2.3.0 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.7.0 +bthome-ble==2.8.0 # homeassistant.components.buienradar buienradar==1.0.5 From b14c5046e2e7172197a557f612b8a2916c198a01 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 5 Mar 2023 23:43:58 +0100 Subject: [PATCH 0243/1058] Reolink add select platform (#87946) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 1 + homeassistant/components/reolink/select.py | 123 ++++++++++++++++++ homeassistant/components/reolink/strings.json | 18 +++ 4 files changed, 143 insertions(+) create mode 100644 homeassistant/components/reolink/select.py diff --git a/.coveragerc b/.coveragerc index 69cd7bf4cab6..e44ca0d70dc8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -982,6 +982,7 @@ omit = homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py homeassistant/components/reolink/number.py + homeassistant/components/reolink/select.py homeassistant/components/reolink/siren.py homeassistant/components/reolink/switch.py homeassistant/components/reolink/update.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 6bc2874285f0..bed286c3bf40 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.NUMBER, + Platform.SELECT, Platform.SIREN, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py new file mode 100644 index 000000000000..8df4afba735d --- /dev/null +++ b/homeassistant/components/reolink/select.py @@ -0,0 +1,123 @@ +"""Component providing support for Reolink select entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum + +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 . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity + + +@dataclass +class ReolinkSelectEntityDescriptionMixin: + """Mixin values for Reolink select entities.""" + + method: Callable[[Host, int, str], Any] + get_options: list[str] | Callable[[Host, int], list[str]] + + +@dataclass +class ReolinkSelectEntityDescription( + SelectEntityDescription, ReolinkSelectEntityDescriptionMixin +): + """A class that describes select entities.""" + + supported: Callable[[Host, int], bool] = lambda api, ch: True + value: Callable[[Host, int], str] | None = None + + +SELECT_ENTITIES = ( + ReolinkSelectEntityDescription( + key="floodlight_mode", + name="Floodlight mode", + icon="mdi:spotlight-beam", + entity_category=EntityCategory.CONFIG, + translation_key="floodlight_mode", + get_options=[mode.name for mode in SpotlightModeEnum], + supported=lambda api, ch: api.supported(ch, "floodLight"), + value=lambda api, ch: SpotlightModeEnum(api.whiteled_mode(ch)).name, + method=lambda api, ch, name: api.set_whiteled(ch, mode=name), + ), + ReolinkSelectEntityDescription( + key="day_night_mode", + name="Day night mode", + icon="mdi:theme-light-dark", + entity_category=EntityCategory.CONFIG, + translation_key="day_night_mode", + get_options=[mode.name for mode in DayNightEnum], + supported=lambda api, ch: api.supported(ch, "dayNight"), + value=lambda api, ch: DayNightEnum(api.daynight_state(ch)).name, + method=lambda api, ch, name: api.set_daynight(ch, DayNightEnum[name].value), + ), + ReolinkSelectEntityDescription( + key="ptz_preset", + name="PTZ preset", + icon="mdi:pan", + get_options=lambda api, ch: list(api.ptz_presets(ch)), + supported=lambda api, ch: api.supported(ch, "ptz_presets"), + method=lambda api, ch, name: api.set_ptz_command(ch, preset=name), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink select entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkSelectEntity(reolink_data, channel, entity_description) + for entity_description in SELECT_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported(reolink_data.host.api, channel) + ) + + +class ReolinkSelectEntity(ReolinkCoordinatorEntity, SelectEntity): + """Base select entity class for Reolink IP cameras.""" + + entity_description: ReolinkSelectEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkSelectEntityDescription, + ) -> None: + """Initialize Reolink select entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + if callable(entity_description.get_options): + self._attr_options = entity_description.get_options(self._host.api, channel) + else: + self._attr_options = entity_description.get_options + + @property + def current_option(self) -> str | None: + """Return the current option.""" + if self.entity_description.value is None: + return None + + return self.entity_description.value(self._host.api, self._channel) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.method(self._host.api, self._channel, option) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index cc609488762b..f4cb8a904ffd 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -43,5 +43,23 @@ "title": "Reolink webhook URL uses HTTPS (SSL)", "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`" } + }, + "entity": { + "select": { + "floodlight_mode": { + "state": { + "off": "Off", + "auto": "Auto", + "schedule": "Schedule" + } + }, + "day_night_mode": { + "state": { + "auto": "Auto", + "color": "Color", + "blackwhite": "Black&White" + } + } + } } } From 216864d8f009d2497d2893aa5640b6e583e5961b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:46:53 +0100 Subject: [PATCH 0244/1058] Refactor WLED switch tests (#89197) --- .../wled/snapshots/test_switch.ambr | 302 ++++++++++++++++++ tests/components/wled/test_switch.py | 217 +++++-------- 2 files changed, 376 insertions(+), 143 deletions(-) create mode 100644 tests/components/wled/snapshots/test_switch.ambr diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr new file mode 100644 index 000000000000..f89bde6ee174 --- /dev/null +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -0,0 +1,302 @@ +# serializer version: 1 +# name: test_switch_state[switch.wled_rgb_light_nightlight-nightlight-called_with_on0-called_with_off0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'duration': 60, + 'fade': True, + 'friendly_name': 'WLED RGB Light Nightlight', + 'icon': 'mdi:weather-night', + 'target_brightness': 0, + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_nightlight', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_nightlight-nightlight-called_with_on0-called_with_off0].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.wled_rgb_light_nightlight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-night', + 'original_name': 'Nightlight', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_nightlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_nightlight-nightlight-called_with_on0-called_with_off0].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_reverse-segment-called_with_on1-called_with_off1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Reverse', + 'icon': 'mdi:swap-horizontal-bold', + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_reverse', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_reverse-segment-called_with_on1-called_with_off1].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.wled_rgb_light_reverse', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:swap-horizontal-bold', + 'original_name': 'Reverse', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_reverse_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_reverse-segment-called_with_on1-called_with_off1].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_receive-sync-called_with_on2-called_with_off2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Sync receive', + 'icon': 'mdi:download-network-outline', + 'udp_port': 21324, + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_sync_receive', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_receive-sync-called_with_on2-called_with_off2].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.wled_rgb_light_sync_receive', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:download-network-outline', + 'original_name': 'Sync receive', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_sync_receive', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_receive-sync-called_with_on2-called_with_off2].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_send-sync-called_with_on3-called_with_off3] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Sync send', + 'icon': 'mdi:upload-network-outline', + 'udp_port': 21324, + }), + 'context': , + 'entity_id': 'switch.wled_rgb_light_sync_send', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_send-sync-called_with_on3-called_with_off3].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.wled_rgb_light_sync_send', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:upload-network-outline', + 'original_name': 'Sync send', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_sync_send', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_state[switch.wled_rgb_light_sync_send-sync-called_with_on3-called_with_off3].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_switch.py b/tests/components/wled/test_switch.py index ef2d3f3ac57f..70804e07eb97 100644 --- a/tests/components/wled/test_switch.py +++ b/tests/components/wled/test_switch.py @@ -3,29 +3,22 @@ import json from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.wled.const import ( - ATTR_DURATION, - ATTR_FADE, - ATTR_TARGET_BRIGHTNESS, - ATTR_UDP_PORT, - SCAN_INTERVAL, -) +from homeassistant.components.wled.const import SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_ICON, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - EntityCategory, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +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, load_fixture @@ -33,163 +26,106 @@ from tests.common import async_fire_time_changed, load_fixture pytestmark = pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("entity_id", "method", "called_with_on", "called_with_off"), + [ + ( + "switch.wled_rgb_light_nightlight", + "nightlight", + {"on": True}, + {"on": False}, + ), + ( + "switch.wled_rgb_light_reverse", + "segment", + {"segment_id": 0, "reverse": True}, + {"segment_id": 0, "reverse": False}, + ), + ( + "switch.wled_rgb_light_sync_receive", + "sync", + {"receive": True}, + {"receive": False}, + ), + ( + "switch.wled_rgb_light_sync_send", + "sync", + {"send": True}, + {"send": False}, + ), + ], +) async def test_switch_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_wled: MagicMock, + entity_id: str, + method: str, + called_with_on: dict[str, bool | int], + called_with_off: dict[str, bool | int], ) -> None: """Test the creation and values of the WLED switches.""" - assert (state := hass.states.get("switch.wled_rgb_light_nightlight")) - assert state.attributes.get(ATTR_DURATION) == 60 - assert state.attributes.get(ATTR_ICON) == "mdi:weather-night" - assert state.attributes.get(ATTR_TARGET_BRIGHTNESS) == 0 - assert state.attributes.get(ATTR_FADE) - assert state.state == STATE_OFF + assert (state := hass.states.get(entity_id)) + assert state == snapshot - assert (entry := entity_registry.async_get("switch.wled_rgb_light_nightlight")) - assert entry.unique_id == "aabbccddeeff_nightlight" - assert entry.entity_category is EntityCategory.CONFIG + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot - assert (state := hass.states.get("switch.wled_rgb_light_sync_send")) - assert state.attributes.get(ATTR_ICON) == "mdi:upload-network-outline" - assert state.attributes.get(ATTR_UDP_PORT) == 21324 - assert state.state == STATE_OFF + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot - assert (entry := entity_registry.async_get("switch.wled_rgb_light_sync_send")) - assert entry.unique_id == "aabbccddeeff_sync_send" - assert entry.entity_category is EntityCategory.CONFIG - - assert (state := hass.states.get("switch.wled_rgb_light_sync_receive")) - assert state.attributes.get(ATTR_ICON) == "mdi:download-network-outline" - assert state.attributes.get(ATTR_UDP_PORT) == 21324 - assert state.state == STATE_ON - - assert (entry := entity_registry.async_get("switch.wled_rgb_light_sync_receive")) - assert entry.unique_id == "aabbccddeeff_sync_receive" - assert entry.entity_category is EntityCategory.CONFIG - - assert (state := hass.states.get("switch.wled_rgb_light_reverse")) - assert state.attributes.get(ATTR_ICON) == "mdi:swap-horizontal-bold" - assert state.state == STATE_OFF - - assert (entry := entity_registry.async_get("switch.wled_rgb_light_reverse")) - assert entry.unique_id == "aabbccddeeff_reverse_0" - assert entry.entity_category is EntityCategory.CONFIG - - -async def test_switch_change_state(hass: HomeAssistant, mock_wled: MagicMock) -> None: - """Test the change of state of the WLED switches.""" - - # Nightlight - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, - blocking=True, - ) - assert mock_wled.nightlight.call_count == 1 - mock_wled.nightlight.assert_called_with(on=True) - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, - blocking=True, - ) - assert mock_wled.nightlight.call_count == 2 - mock_wled.nightlight.assert_called_with(on=False) - - # Sync send - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"}, - blocking=True, - ) - assert mock_wled.sync.call_count == 1 - mock_wled.sync.assert_called_with(send=True) - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_send"}, - blocking=True, - ) - assert mock_wled.sync.call_count == 2 - mock_wled.sync.assert_called_with(send=False) - - # Sync receive - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"}, - blocking=True, - ) - assert mock_wled.sync.call_count == 3 - mock_wled.sync.assert_called_with(receive=False) + # Test on/off services + method_mock = getattr(mock_wled, method) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_sync_receive"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert mock_wled.sync.call_count == 4 - mock_wled.sync.assert_called_with(receive=True) - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_reverse"}, - blocking=True, - ) - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=0, reverse=True) + assert method_mock.call_count == 1 + method_mock.assert_called_with(**called_with_on) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_reverse"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert mock_wled.segment.call_count == 2 - mock_wled.segment.assert_called_with(segment_id=0, reverse=False) + assert method_mock.call_count == 2 + method_mock.assert_called_with(**called_with_off) -async def test_switch_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED switches.""" - mock_wled.nightlight.side_effect = WLEDError - + # Test invalid response, not becoming unavailable + method_mock.side_effect = WLEDError with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - state = hass.states.get("switch.wled_rgb_light_nightlight") - assert state - assert state.state == STATE_OFF - - -async def test_switch_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED switches.""" - mock_wled.nightlight.side_effect = WLEDConnectionError + assert method_mock.call_count == 3 + assert (state := hass.states.get(state.entity_id)) + assert state.state != STATE_UNAVAILABLE + # Test connection error, leading to becoming unavailable + method_mock.side_effect = WLEDConnectionError with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wled_rgb_light_nightlight"}, + {ATTR_ENTITY_ID: state.entity_id}, blocking=True, ) - assert (state := hass.states.get("switch.wled_rgb_light_nightlight")) + assert method_mock.call_count == 4 + assert (state := hass.states.get(state.entity_id)) assert state.state == STATE_UNAVAILABLE @@ -199,11 +135,10 @@ async def test_switch_dynamically_handle_segments( mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - segment0 = hass.states.get("switch.wled_rgb_light_reverse") - segment1 = hass.states.get("switch.wled_rgb_light_segment_1_reverse") - assert segment0 + + assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) assert segment0.state == STATE_OFF - assert not segment1 + assert not hass.states.get("switch.wled_rgb_light_segment_1_reverse") # Test adding a segment dynamically... return_value = mock_wled.update.return_value @@ -214,11 +149,9 @@ async def test_switch_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("switch.wled_rgb_light_reverse") - segment1 = hass.states.get("switch.wled_rgb_light_segment_1_reverse") - assert segment0 + assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) assert segment0.state == STATE_OFF - assert segment1 + assert (segment1 := hass.states.get("switch.wled_rgb_light_segment_1_reverse")) assert segment1.state == STATE_ON # Test remove segment again... @@ -226,9 +159,7 @@ async def test_switch_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - segment0 = hass.states.get("switch.wled_rgb_light_reverse") - segment1 = hass.states.get("switch.wled_rgb_light_segment_1_reverse") - assert segment0 + assert (segment0 := hass.states.get("switch.wled_rgb_light_reverse")) assert segment0.state == STATE_OFF - assert segment1 + assert (segment1 := hass.states.get("switch.wled_rgb_light_segment_1_reverse")) assert segment1.state == STATE_UNAVAILABLE From 3e1d9deb2970af6f5e3b68da5042de922b3aa3fa Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:47:07 +0100 Subject: [PATCH 0245/1058] Update coverage to 7.2.1 (#89196) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 75153ea6a0a1..0093240e0cf0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,7 +9,7 @@ -r requirements_test_pre_commit.txt astroid==2.14.1 codecov==2.1.12 -coverage==7.1.0 +coverage==7.2.1 freezegun==1.2.2 mock-open==1.4.0 mypy==1.0.1 From 570db2a0af9aad8cded4ec6302758a6fe05a6b40 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:47:19 +0100 Subject: [PATCH 0246/1058] Update sentry-sdk to 1.16.0 (#89193) --- 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 91da03209961..95eff4e7a552 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.13.0"] + "requirements": ["sentry-sdk==1.16.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2bd1992333c1..08455e47a51a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2328,7 +2328,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.13.0 +sentry-sdk==1.16.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5adbd10f709e..bc825bd3b27c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1652,7 +1652,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.13.0 +sentry-sdk==1.16.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From 74566258ba7e721d728a5d2a087c73c54158ccc5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:47:31 +0100 Subject: [PATCH 0247/1058] Update watchdog to 2.3.1 (#89190) --- homeassistant/components/folder_watcher/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index 31a199ab88d4..96decd0b8cf6 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["watchdog"], "quality_scale": "internal", - "requirements": ["watchdog==2.2.1"] + "requirements": ["watchdog==2.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 08455e47a51a..cc83b2eab038 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2614,7 +2614,7 @@ wallbox==0.4.12 waqiasync==1.0.0 # homeassistant.components.folder_watcher -watchdog==2.2.1 +watchdog==2.3.1 # homeassistant.components.waterfurnace waterfurnace==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc825bd3b27c..1f4b0c06bc6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1860,7 +1860,7 @@ wakeonlan==2.1.0 wallbox==0.4.12 # homeassistant.components.folder_watcher -watchdog==2.2.1 +watchdog==2.3.1 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.2 From a0ff95cef828ac0bea32015de5cdca3a53aebb12 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:47:52 +0100 Subject: [PATCH 0248/1058] Update pytest to 7.2.2 (#89179) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 0093240e0cf0..7f2e51a0c08d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -29,7 +29,7 @@ pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==2.5.0 -pytest==7.2.1 +pytest==7.2.2 requests_mock==1.10.0 respx==0.20.1 syrupy==4.0.0 From ff485d4648ecf058a33e2cb7f5d7bd31be5f6845 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 01:49:01 +0100 Subject: [PATCH 0249/1058] Refactor WLED number tests (#88582) --- .../wled/snapshots/test_number.ambr | 335 ++++++++++++++++++ tests/components/wled/test_number.py | 300 +++++----------- 2 files changed, 426 insertions(+), 209 deletions(-) create mode 100644 tests/components/wled/snapshots/test_number.ambr diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr new file mode 100644 index 000000000000..96b465616c4c --- /dev/null +++ b/tests/components/wled/snapshots/test_number.ambr @@ -0,0 +1,335 @@ +# serializer version: 1 +# name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 Intensity', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.wled_rgb_light_segment_1_intensity', + 'last_changed': , + 'last_updated': , + 'state': '64', + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wled_rgb_light_segment_1_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Segment 1 Intensity', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_intensity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_intensity-42-intensity].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 Speed', + 'icon': 'mdi:speedometer', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.wled_rgb_light_segment_1_speed', + 'last_changed': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wled_rgb_light_segment_1_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:speedometer', + 'original_name': 'Segment 1 Speed', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_speed_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number.wled_rgb_light_segment_1_speed-42-speed].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_intensity-42-intensity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 Intensity', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.wled_rgb_light_segment_1_intensity', + 'last_changed': , + 'last_updated': , + 'state': '64', + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_intensity-42-intensity].1 + EntityRegistryEntrySnapshot({ + '_display_repr': , + '_partial_repr': , + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wled_rgb_light_segment_1_intensity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Segment 1 Intensity', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_intensity_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_intensity-42-intensity].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_speed-42-speed] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 Speed', + 'icon': 'mdi:speedometer', + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.wled_rgb_light_segment_1_speed', + 'last_changed': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_speed-42-speed].1 + EntityRegistryEntrySnapshot({ + '_display_repr': , + '_partial_repr': , + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 255, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wled_rgb_light_segment_1_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:speedometer', + 'original_name': 'Segment 1 Speed', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_speed_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_speed_state[number.wled_rgb_light_segment_1_speed-42-speed].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_number.py b/tests/components/wled/test_number.py index 150db4951556..59f2fb123320 100644 --- a/tests/components/wled/test_number.py +++ b/tests/components/wled/test_number.py @@ -3,21 +3,19 @@ import json from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError from homeassistant.components.number import ( - ATTR_MAX, - ATTR_MIN, - ATTR_STEP, ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.components.wled.const import SCAN_INTERVAL -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +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, load_fixture @@ -25,52 +23,106 @@ from tests.common import async_fire_time_changed, load_fixture pytestmark = pytest.mark.usefixtures("init_integration") -async def test_speed_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry +@pytest.mark.parametrize( + ("entity_id", "value", "called_arg"), + [ + ("number.wled_rgb_light_segment_1_speed", 42, "speed"), + ("number.wled_rgb_light_segment_1_intensity", 42, "intensity"), + ], +) +async def test_numbers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_wled: MagicMock, + entity_id: str, + value: int, + called_arg: str, ) -> None: """Test the creation and values of the WLED numbers.""" - # First segment of the strip - assert (state := hass.states.get("number.wled_rgb_light_segment_1_speed")) - assert state.attributes.get(ATTR_ICON) == "mdi:speedometer" - assert state.attributes.get(ATTR_MAX) == 255 - assert state.attributes.get(ATTR_MIN) == 0 - assert state.attributes.get(ATTR_STEP) == 1 - assert state.state == "16" + assert (state := hass.states.get(entity_id)) + assert state == snapshot - assert (entry := entity_registry.async_get("number.wled_rgb_light_segment_1_speed")) - assert entry.unique_id == "aabbccddeeff_speed_1" + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot -async def test_speed_segment_change_state( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test the value change of the WLED segments.""" + # Test a regular state change service call await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_speed", - ATTR_VALUE: 42, - }, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, blocking=True, ) + assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with( - segment_id=1, - speed=42, - ) + mock_wled.segment.assert_called_with(segment_id=1, **{called_arg: value}) + + # Test with WLED error + mock_wled.segment.side_effect = WLEDError + with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + assert mock_wled.segment.call_count == 2 + + # Ensure the entity is still available + assert (state := hass.states.get(entity_id)) + assert state.state != STATE_UNAVAILABLE + + # Test when a connection error occurs + mock_wled.segment.side_effect = WLEDConnectionError + with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + assert mock_wled.segment.call_count == 3 + + # Ensure the entity became unavailable after the connection error + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) +@pytest.mark.parametrize( + ("entity_id_segment0", "state_segment0", "entity_id_segment1", "state_segment1"), + [ + ( + "number.wled_rgb_light_speed", + "32", + "number.wled_rgb_light_segment_1_speed", + "16", + ), + ( + "number.wled_rgb_light_intensity", + "128", + "number.wled_rgb_light_segment_1_intensity", + "64", + ), + ], +) async def test_speed_dynamically_handle_segments( hass: HomeAssistant, mock_wled: MagicMock, + entity_id_segment0: str, + entity_id_segment1: str, + state_segment0: str, + state_segment1: str, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - assert (segment0 := hass.states.get("number.wled_rgb_light_speed")) - assert segment0.state == "32" - assert not hass.states.get("number.wled_rgb_light_segment_1_speed") + assert (segment0 := hass.states.get(entity_id_segment0)) + assert segment0.state == state_segment0 + assert not hass.states.get(entity_id_segment1) # Test adding a segment dynamically... return_value = mock_wled.update.return_value @@ -81,187 +133,17 @@ async def test_speed_dynamically_handle_segments( async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - assert (segment0 := hass.states.get("number.wled_rgb_light_speed")) - assert segment0.state == "32" - assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_speed")) - assert segment1.state == "16" + assert (segment0 := hass.states.get(entity_id_segment0)) + assert segment0.state == state_segment0 + assert (segment1 := hass.states.get(entity_id_segment1)) + assert segment1.state == state_segment1 # Test remove segment again... mock_wled.update.return_value = return_value async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - assert (segment0 := hass.states.get("number.wled_rgb_light_speed")) - assert segment0.state == "32" - assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_speed")) + assert (segment0 := hass.states.get(entity_id_segment0)) + assert segment0.state == state_segment0 + assert (segment1 := hass.states.get(entity_id_segment1)) assert segment1.state == STATE_UNAVAILABLE - - -async def test_speed_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED numbers.""" - mock_wled.segment.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_speed", - ATTR_VALUE: 42, - }, - blocking=True, - ) - - assert (state := hass.states.get("number.wled_rgb_light_segment_1_speed")) - assert state.state == "16" - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, speed=42) - - -async def test_speed_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED numbers.""" - mock_wled.segment.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_speed", - ATTR_VALUE: 42, - }, - blocking=True, - ) - - assert (state := hass.states.get("number.wled_rgb_light_segment_1_speed")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, speed=42) - - -async def test_intensity_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test the creation and values of the WLED numbers.""" - # First segment of the strip - assert (state := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_MAX) == 255 - assert state.attributes.get(ATTR_MIN) == 0 - assert state.attributes.get(ATTR_STEP) == 1 - assert state.state == "64" - - assert ( - entry := entity_registry.async_get("number.wled_rgb_light_segment_1_intensity") - ) - assert entry.unique_id == "aabbccddeeff_intensity_1" - - -async def test_intensity_segment_change_state( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test the value change of the WLED segments.""" - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_intensity", - ATTR_VALUE: 128, - }, - blocking=True, - ) - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with( - segment_id=1, - intensity=128, - ) - - -@pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) -async def test_intensity_dynamically_handle_segments( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test if a new/deleted segment is dynamically added/removed.""" - assert (segment0 := hass.states.get("number.wled_rgb_light_intensity")) - assert segment0.state == "128" - assert not hass.states.get("number.wled_rgb_light_segment_1_intensity") - - # Test adding a segment dynamically... - return_value = mock_wled.update.return_value - mock_wled.update.return_value = WLEDDevice( - json.loads(load_fixture("wled/rgb.json")) - ) - - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) - await hass.async_block_till_done() - - assert (segment0 := hass.states.get("number.wled_rgb_light_intensity")) - assert segment0.state == "128" - assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert segment1.state == "64" - - # Test remove segment again... - mock_wled.update.return_value = return_value - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) - await hass.async_block_till_done() - - assert (segment0 := hass.states.get("number.wled_rgb_light_intensity")) - assert segment0.state == "128" - assert (segment1 := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert segment1.state == STATE_UNAVAILABLE - - -async def test_intensity_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED numbers.""" - mock_wled.segment.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_intensity", - ATTR_VALUE: 21, - }, - blocking=True, - ) - - assert (state := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert state.state == "64" - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, intensity=21) - - -async def test_intensity_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED numbers.""" - mock_wled.segment.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: "number.wled_rgb_light_segment_1_intensity", - ATTR_VALUE: 128, - }, - blocking=True, - ) - - assert (state := hass.states.get("number.wled_rgb_light_segment_1_intensity")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, intensity=128) From 36dabaaea64d0062bf136bf507f7108713cbd527 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 6 Mar 2023 02:19:42 +0100 Subject: [PATCH 0250/1058] Fix lingering tasks in KNX tests (#89201) --- tests/components/knx/README.md | 1 + tests/components/knx/test_climate.py | 39 ++++++++++++++++------------ tests/components/knx/test_cover.py | 4 +++ 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md index 4b5886200c4b..930b9e71c288 100644 --- a/tests/components/knx/README.md +++ b/tests/components/knx/README.md @@ -69,3 +69,4 @@ Receive some telegrams and assert state. - For `payload` in `assert_*` and `receive_*` use `int` for DPT 1, 2 and 3 payload values (DPTBinary) and `tuple` for other DPTs (DPTArray). - `await self.hass.async_block_till_done()` is called before `KNXTestKit.assert_*` and after `KNXTestKit.receive_*` so you don't have to explicitly call it. - Make sure to assert every outgoing telegram that was created in a test. `assert_no_telegram` is automatically called on teardown. +- Make sure to `knx.receive_response()` for every Read-request sent form StateUpdater, or to pass its timeout, to not have lingering tasks when finishing the tests. diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index e10ac76cb404..ac6f5e7e9727 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -10,6 +10,10 @@ from .conftest import KNXTestKit from tests.common import async_capture_events +RAW_FLOAT_20_0 = (0x07, 0xD0) +RAW_FLOAT_21_0 = (0x0C, 0x1A) +RAW_FLOAT_22_0 = (0x0C, 0x4C) + async def test_climate_basic_temperature_set( hass: HomeAssistant, knx: KNXTestKit @@ -34,6 +38,10 @@ async def test_climate_basic_temperature_set( await knx.assert_read("1/2/3") # read target temperature await knx.assert_read("1/2/5") + # StateUpdater initialize state + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + events.clear() # set new temperature await hass.services.async_call( @@ -42,9 +50,8 @@ async def test_climate_basic_temperature_set( {"entity_id": "climate.test", "temperature": 20}, blocking=True, ) - await knx.assert_write("1/2/4", (7, 208)) + await knx.assert_write("1/2/4", RAW_FLOAT_20_0) assert len(events) == 1 - events.pop() async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: @@ -73,11 +80,12 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/7") await knx.assert_read("1/2/3") # StateUpdater initialize state - await knx.receive_response("1/2/7", True) - await knx.receive_response("1/2/3", (0x21,)) + await knx.receive_response("1/2/7", (0x01,)) + await knx.receive_response("1/2/3", RAW_FLOAT_20_0) # StateUpdater semaphore allows 2 concurrent requests # read target temperature state await knx.assert_read("1/2/5") + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) # turn hvac off await hass.services.async_call( @@ -125,11 +133,13 @@ async def test_climate_preset_mode( await knx.assert_read("1/2/7") await knx.assert_read("1/2/3") # StateUpdater initialize state - await knx.receive_response("1/2/7", True) - await knx.receive_response("1/2/3", (0x01,)) + await knx.receive_response("1/2/7", (0x01,)) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) # StateUpdater semaphore allows 2 concurrent requests # read target temperature state await knx.assert_read("1/2/5") + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + events.clear() # set preset mode await hass.services.async_call( @@ -190,10 +200,11 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/7") await knx.assert_read("1/2/3") # StateUpdater initialize state - await knx.receive_response("1/2/7", True) - await knx.receive_response("1/2/3", (0x01,)) + await knx.receive_response("1/2/7", (0x01,)) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) # StateUpdater semaphore allows 2 concurrent requests await knx.assert_read("1/2/5") + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) # verify update entity retriggers group value reads to the bus await hass.services.async_call( @@ -210,7 +221,6 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate command_value.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -222,20 +232,17 @@ async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() await hass.async_block_till_done() # read states state updater await knx.assert_read("1/2/3") await knx.assert_read("1/2/5") # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + # StateUpdater semaphore allows 2 concurrent requests + await knx.assert_read("1/2/6") await knx.receive_response("1/2/6", (0x32,)) - await knx.receive_response("1/2/3", (0x0C, 0x1A)) - - assert len(events) == 2 - events.pop() knx.assert_state("climate.test", HVACMode.HEAT, command_value=20) diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index e2c462e74ecd..ed82968b559d 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -31,6 +31,10 @@ async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: # read position state address and angle state address await knx.assert_read("1/0/2") await knx.assert_read("1/0/4") + # StateUpdater initialize state + await knx.receive_response("1/0/2", (0x0F,)) + await knx.receive_response("1/0/4", (0x30,)) + events.clear() # open cover await hass.services.async_call( From 811e286f0fb0f53351875e86cac84a9ecc8c7074 Mon Sep 17 00:00:00 2001 From: ztamas83 <71548739+ztamas83@users.noreply.github.com> Date: Mon, 6 Mar 2023 02:38:47 +0100 Subject: [PATCH 0251/1058] Test coverage for Tibber config flow (#89088) * Test coverage for Tibber config flow * Fix isort and ruff errors --- .../components/tibber/config_flow.py | 9 ++-- tests/components/tibber/test_config_flow.py | 52 ++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index b5cb4486cc93..fbd2345fb80d 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -16,6 +16,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +ERR_TIMEOUT = "timeout" +ERR_CLIENT = "cannot_connect" +ERR_TOKEN = "invalid_access_token" class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -43,15 +46,15 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await tibber_connection.update_info() except asyncio.TimeoutError: - errors[CONF_ACCESS_TOKEN] = "timeout" + errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT except tibber.InvalidLogin: - errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + errors[CONF_ACCESS_TOKEN] = ERR_TOKEN except ( aiohttp.ClientError, tibber.RetryableHttpException, tibber.FatalHttpException, ): - errors[CONF_ACCESS_TOKEN] = "cannot_connect" + errors[CONF_ACCESS_TOKEN] = ERR_CLIENT if errors: return self.async_show_form( diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index d50e60b1588d..e07e4d66cd2e 100644 --- a/tests/components/tibber/test_config_flow.py +++ b/tests/components/tibber/test_config_flow.py @@ -1,13 +1,22 @@ """Tests for Tibber config flow.""" +from asyncio import TimeoutError from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from aiohttp import ClientError import pytest +from tibber import FatalHttpException, InvalidLogin, RetryableHttpException from homeassistant import config_entries from homeassistant.components.recorder import Recorder +from homeassistant.components.tibber.config_flow import ( + ERR_CLIENT, + ERR_TIMEOUT, + ERR_TOKEN, +) from homeassistant.components.tibber.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType @pytest.fixture(name="tibber_setup", autouse=True) @@ -23,7 +32,7 @@ async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) -> DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" @@ -46,14 +55,45 @@ async def test_create_entry(recorder_mock: Recorder, hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data ) - assert result["type"] == "create_entry" + assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == title assert result["data"] == test_data -async def test_flow_entry_already_exists( - recorder_mock: Recorder, hass: HomeAssistant, config_entry -) -> None: +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (TimeoutError, ERR_TIMEOUT), + (ClientError, ERR_CLIENT), + (InvalidLogin(401), ERR_TOKEN), + (RetryableHttpException(503), ERR_CLIENT), + (FatalHttpException(404), ERR_CLIENT), + ], +) +async def test_create_entry_exceptions(recorder_mock, hass, exception, expected_error): + """Test create entry from user input.""" + test_data = { + CONF_ACCESS_TOKEN: "valid", + } + + unique_user_id = "unique_user_id" + title = "title" + + tibber_mock = MagicMock() + type(tibber_mock).update_info = AsyncMock(side_effect=exception) + type(tibber_mock).user_id = PropertyMock(return_value=unique_user_id) + type(tibber_mock).name = PropertyMock(return_value=title) + + with patch("tibber.Tibber", return_value=tibber_mock): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"][CONF_ACCESS_TOKEN] == expected_error + + +async def test_flow_entry_already_exists(recorder_mock, hass, config_entry): """Test user input for config_entry that already exists.""" test_data = { CONF_ACCESS_TOKEN: "valid", @@ -64,5 +104,5 @@ async def test_flow_entry_already_exists( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data ) - assert result["type"] == "abort" + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" From 876776e2915c8c1d18b6bdfd82a0f25dc3d6b2ea Mon Sep 17 00:00:00 2001 From: MarkGodwin Date: Mon, 6 Mar 2023 04:47:45 +0000 Subject: [PATCH 0252/1058] Fix host IP and scheme entry issues in TP-Link Omada (#89130) Fixing host IP and scheme entry issues --- .../components/tplink_omada/config_flow.py | 27 +++++++- .../tplink_omada/test_config_flow.py | 68 +++++++++++++++++-- 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index 6b958b7d258b..f6a75abe6d87 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -3,9 +3,12 @@ from __future__ import annotations from collections.abc import Mapping import logging +import re from types import MappingProxyType from typing import Any, NamedTuple +from urllib.parse import urlsplit +from aiohttp import CookieJar from tplink_omada_client.exceptions import ( ConnectionFailed, LoginFailed, @@ -20,7 +23,10 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import ( + async_create_clientsession, + async_get_clientsession, +) from .const import DOMAIN @@ -42,11 +48,26 @@ async def create_omada_client( hass: HomeAssistant, data: MappingProxyType[str, Any] ) -> OmadaClient: """Create a TP-Link Omada client API for the given config entry.""" - host = data[CONF_HOST] + + host: str = data[CONF_HOST] verify_ssl = bool(data[CONF_VERIFY_SSL]) + + if not host.lower().startswith(("http://", "https://")): + host = "https://" + host + host_parts = urlsplit(host) + if ( + host_parts.hostname + and re.fullmatch(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", host_parts.hostname) + 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)) + else: + websession = async_get_clientsession(hass, verify_ssl=verify_ssl) + username = data[CONF_USERNAME] password = data[CONF_PASSWORD] - websession = async_get_clientsession(hass, verify_ssl=verify_ssl) + return OmadaClient(host, username, password, websession=websession) diff --git a/tests/components/tplink_omada/test_config_flow.py b/tests/components/tplink_omada/test_config_flow.py index fd32b357b7c1..cf3fddf59434 100644 --- a/tests/components/tplink_omada/test_config_flow.py +++ b/tests/components/tplink_omada/test_config_flow.py @@ -22,14 +22,14 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry MOCK_USER_DATA = { - "host": "1.1.1.1", + "host": "https://fake.omada.host", "verify_ssl": True, "username": "test-username", "password": "test-password", } MOCK_ENTRY_DATA = { - "host": "1.1.1.1", + "host": "https://fake.omada.host", "verify_ssl": True, "site": "SiteId", "username": "test-username", @@ -111,7 +111,7 @@ async def test_form_multiple_sites(hass: HomeAssistant) -> None: assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "OC200 (Site 2)" assert result3["data"] == { - "host": "1.1.1.1", + "host": "https://fake.omada.host", "verify_ssl": True, "site": "second", "username": "test-username", @@ -272,7 +272,7 @@ async def test_async_step_reauth_success(hass: HomeAssistant) -> None: mocked_validate.assert_called_once_with( hass, { - "host": "1.1.1.1", + "host": "https://fake.omada.host", "verify_ssl": True, "site": "SiteId", "username": "new_uname", @@ -353,6 +353,64 @@ async def test_create_omada_client_parses_args(hass: HomeAssistant) -> None: assert result is not None mock_client.assert_called_once_with( - "1.1.1.1", "test-username", "test-password", "ws" + "https://fake.omada.host", "test-username", "test-password", "ws" ) mock_clientsession.assert_called_once_with(hass, verify_ssl=True) + + +async def test_create_omada_client_adds_missing_scheme(hass: HomeAssistant) -> None: + """Test config arguments are passed to Omada client.""" + + with patch( + "homeassistant.components.tplink_omada.config_flow.OmadaClient", autospec=True + ) as mock_client, patch( + "homeassistant.components.tplink_omada.config_flow.async_get_clientsession", + return_value="ws", + ) as mock_clientsession: + result = await create_omada_client( + hass, + { + "host": "fake.omada.host", + "verify_ssl": True, + "username": "test-username", + "password": "test-password", + }, + ) + + assert result is not None + mock_client.assert_called_once_with( + "https://fake.omada.host", "test-username", "test-password", "ws" + ) + mock_clientsession.assert_called_once_with(hass, verify_ssl=True) + + +async def test_create_omada_client_with_ip_creates_clientsession( + hass: HomeAssistant, +) -> None: + """Test config arguments are passed to Omada client.""" + + with patch( + "homeassistant.components.tplink_omada.config_flow.OmadaClient", autospec=True + ) as mock_client, patch( + "homeassistant.components.tplink_omada.config_flow.CookieJar", autospec=True + ) as mock_jar, patch( + "homeassistant.components.tplink_omada.config_flow.async_create_clientsession", + return_value="ws", + ) as mock_create_clientsession: + result = await create_omada_client( + hass, + { + "host": "10.10.10.10", + "verify_ssl": True, # Verify is meaningless for IP + "username": "test-username", + "password": "test-password", + }, + ) + + assert result is not None + mock_client.assert_called_once_with( + "https://10.10.10.10", "test-username", "test-password", "ws" + ) + mock_create_clientsession.assert_called_once_with( + hass, cookie_jar=mock_jar.return_value + ) From fc673139cd191779a66b4170f969e0e451704c50 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Mon, 6 Mar 2023 08:29:41 +0100 Subject: [PATCH 0253/1058] Add device info to edl21 (#89070) * Added device info for edl21. * Apply suggestions from code review --- homeassistant/components/edl21/sensor.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 355d448e3016..5b1a677eddb2 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -32,6 +32,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity import 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 @@ -283,7 +284,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.2.0", + breaks_in_ha_version="2023.5.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", @@ -397,7 +398,7 @@ class EDL21: old_entity_id = registry.async_get_entity_id( "sensor", DOMAIN, entity.old_unique_id ) - if old_entity_id is not None: + if old_entity_id is not None and entity.unique_id is not None: LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity.old_unique_id, @@ -422,8 +423,6 @@ class EDL21Entity(SensorEntity): """Initialize an EDL21Entity.""" self._electricity_id = electricity_id self._obis = obis - self._name = name - self._unique_id = f"{electricity_id}_{obis}" self._telegram = telegram self._min_time = MIN_TIME_BETWEEN_UPDATES self._last_update = utcnow() @@ -435,6 +434,12 @@ class EDL21Entity(SensorEntity): } self._async_remove_dispatcher = None self.entity_description = entity_description + self._attr_name = name + self._attr_unique_id = f"{electricity_id}_{obis}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._electricity_id)}, + name=self._electricity_id, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -466,21 +471,11 @@ class EDL21Entity(SensorEntity): if self._async_remove_dispatcher: self._async_remove_dispatcher() - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - @property def old_unique_id(self) -> str: """Return a less unique ID as used in the first version of edl21.""" return self._obis - @property - def name(self) -> str | None: - """Return a name.""" - return self._name - @property def native_value(self) -> str: """Return the value of the last received telegram.""" From c7b30b61de68ebd5a098c8c4aa334e887f790d6d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 6 Mar 2023 08:51:33 +0100 Subject: [PATCH 0254/1058] Revert "Add device info to edl21" (#89217) --- homeassistant/components/edl21/sensor.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 5b1a677eddb2..355d448e3016 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -32,7 +32,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import 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 @@ -284,7 +283,7 @@ async def async_setup_platform( hass, DOMAIN, "deprecated_yaml", - breaks_in_ha_version="2023.5.0", + breaks_in_ha_version="2023.2.0", is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", @@ -398,7 +397,7 @@ class EDL21: old_entity_id = registry.async_get_entity_id( "sensor", DOMAIN, entity.old_unique_id ) - if old_entity_id is not None and entity.unique_id is not None: + if old_entity_id is not None: LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity.old_unique_id, @@ -423,6 +422,8 @@ class EDL21Entity(SensorEntity): """Initialize an EDL21Entity.""" self._electricity_id = electricity_id self._obis = obis + self._name = name + self._unique_id = f"{electricity_id}_{obis}" self._telegram = telegram self._min_time = MIN_TIME_BETWEEN_UPDATES self._last_update = utcnow() @@ -434,12 +435,6 @@ class EDL21Entity(SensorEntity): } self._async_remove_dispatcher = None self.entity_description = entity_description - self._attr_name = name - self._attr_unique_id = f"{electricity_id}_{obis}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._electricity_id)}, - name=self._electricity_id, - ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -471,11 +466,21 @@ class EDL21Entity(SensorEntity): if self._async_remove_dispatcher: self._async_remove_dispatcher() + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + @property def old_unique_id(self) -> str: """Return a less unique ID as used in the first version of edl21.""" return self._obis + @property + def name(self) -> str | None: + """Return a name.""" + return self._name + @property def native_value(self) -> str: """Return the value of the last received telegram.""" From 0c65af93af7dc6236e0be9816b33b9f370c3d4ea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 10:02:32 +0100 Subject: [PATCH 0255/1058] Split reauth tests in plex (#89212) --- tests/components/plex/conftest.py | 12 +++- tests/components/plex/test_config_flow.py | 69 +++++++++-------------- tests/components/plex/test_init.py | 26 ++++++++- 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 506aadcce612..e4bf61ccd949 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -1,5 +1,6 @@ """Fixtures for Plex tests.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch import pytest @@ -18,6 +19,15 @@ def plex_server_url(entry): return entry.data[PLEX_SERVER_CONFIG][CONF_URL].split(":", 1)[-1] +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.plex.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="album", scope="session") def album_fixture(): """Load album payload and return it.""" diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 36c9ab614f53..2f3e268177ba 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -2,7 +2,7 @@ import copy from http import HTTPStatus import ssl -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import plexapi.exceptions import pytest @@ -42,7 +42,6 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .const import DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN, PLEX_DIRECT_URL -from .helpers import trigger_plex_update, wait_for_debouncer from .mock_classes import MockGDM from tests.common import MockConfigEntry @@ -718,31 +717,22 @@ async def test_integration_discovery(hass: HomeAssistant) -> None: assert flow["step_id"] == "user" -async def test_trigger_reauth( +async def test_reauth( hass: HomeAssistant, - entry, - mock_plex_server, - mock_websocket, + entry: MockConfigEntry, + mock_plex_calls: None, current_request_with_host: None, + mock_setup_entry: AsyncMock, ) -> None: """Test setup and reauthorization of a Plex token.""" + entry.add_to_hass(hass) - assert entry.state is ConfigEntryState.LOADED - - with patch( - "plexapi.server.PlexServer.clients", side_effect=plexapi.exceptions.Unauthorized - ), patch("plexapi.server.PlexServer", side_effect=plexapi.exceptions.Unauthorized): - trigger_plex_update(mock_websocket) - await wait_for_debouncer(hass) - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is not ConfigEntryState.LOADED - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["context"]["source"] == SOURCE_REAUTH - - flow_id = flows[0]["flow_id"] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry.data, + ) + flow_id = result["flow_id"] with patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" @@ -767,38 +757,33 @@ async def test_trigger_reauth( assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" + mock_setup_entry.assert_called_once() -async def test_trigger_reauth_multiple_servers_available( + +async def test_reauth_multiple_servers_available( hass: HomeAssistant, - entry, - mock_plex_server, - mock_websocket, + entry: MockConfigEntry, + mock_plex_calls: None, current_request_with_host: None, requests_mock: requests_mock.Mocker, - plextv_resources_two_servers, + plextv_resources_two_servers: str, + mock_setup_entry: AsyncMock, ) -> None: """Test setup and reauthorization of a Plex token when multiple servers are available.""" - assert entry.state is ConfigEntryState.LOADED - requests_mock.get( "https://plex.tv/api/resources", text=plextv_resources_two_servers, ) - with patch( - "plexapi.server.PlexServer.clients", side_effect=plexapi.exceptions.Unauthorized - ), patch("plexapi.server.PlexServer", side_effect=plexapi.exceptions.Unauthorized): - trigger_plex_update(mock_websocket) - await wait_for_debouncer(hass) + entry.add_to_hass(hass) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state is not ConfigEntryState.LOADED + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH}, + data=entry.data, + ) - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["context"]["source"] == SOURCE_REAUTH - - flow_id = flows[0]["flow_id"] + flow_id = result["flow_id"] with patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value="BRAND_NEW_TOKEN" @@ -823,6 +808,8 @@ async def test_trigger_reauth_multiple_servers_available( assert entry.data[PLEX_SERVER_CONFIG][CONF_URL] == PLEX_DIRECT_URL assert entry.data[PLEX_SERVER_CONFIG][CONF_TOKEN] == "BRAND_NEW_TOKEN" + mock_setup_entry.assert_called_once() + async def test_client_request_missing(hass: HomeAssistant) -> None: """Test when client headers are not set properly.""" diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index cdfa409237fc..08b8635829fd 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -15,7 +15,7 @@ from homeassistant.components.plex.models import ( TRANSIENT_SECTION, UNKNOWN_SECTION, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( CONF_TOKEN, CONF_URL, @@ -336,3 +336,27 @@ async def test_setup_with_limited_credentials( assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED + + +async def test_trigger_reauth( + hass: HomeAssistant, + entry: MockConfigEntry, + mock_plex_server, + mock_websocket, +) -> None: + """Test setup and reauthorization of a Plex token.""" + + assert entry.state is ConfigEntryState.LOADED + + with patch( + "plexapi.server.PlexServer.clients", side_effect=plexapi.exceptions.Unauthorized + ), patch("plexapi.server.PlexServer", side_effect=plexapi.exceptions.Unauthorized): + trigger_plex_update(mock_websocket) + await wait_for_debouncer(hass) + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state is not ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH From ab1df8065c510015fd2021ad93eae2c3eaa92227 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 6 Mar 2023 10:26:37 +0100 Subject: [PATCH 0256/1058] Refresh homeassistant_alerts when components are loaded (#76049) --- .../homeassistant_alerts/__init__.py | 18 +- .../homeassistant_alerts/test_init.py | 233 +++++++++++++++++- 2 files changed, 247 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 7012111ed615..ffc0594baf3b 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -10,9 +10,10 @@ import aiohttp from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.hassio import get_supervisor_info, is_hassio -from homeassistant.const import __version__ -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import EVENT_COMPONENT_LOADED, __version__ +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -22,6 +23,7 @@ from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +COMPONENT_LOADED_COOLDOWN = 30 DOMAIN = "homeassistant_alerts" UPDATE_INTERVAL = timedelta(hours=3) _LOGGER = logging.getLogger(__name__) @@ -85,7 +87,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: coordinator.async_add_listener(async_schedule_update_alerts) async def initial_refresh(hass: HomeAssistant) -> None: + refresh_debouncer = Debouncer( + hass, + _LOGGER, + cooldown=COMPONENT_LOADED_COOLDOWN, + immediate=False, + function=coordinator.async_refresh, + ) + + async def _component_loaded(_: Event) -> None: + await refresh_debouncer.async_call() + await coordinator.async_refresh() + hass.bus.async_listen(EVENT_COMPONENT_LOADED, _component_loaded) async_at_start(hass, initial_refresh) diff --git a/tests/components/homeassistant_alerts/test_init.py b/tests/components/homeassistant_alerts/test_init.py index 9bb54bd56af3..f5e040aa3895 100644 --- a/tests/components/homeassistant_alerts/test_init.py +++ b/tests/components/homeassistant_alerts/test_init.py @@ -7,10 +7,15 @@ from unittest.mock import ANY, patch import pytest -from homeassistant.components.homeassistant_alerts import DOMAIN, UPDATE_INTERVAL +from homeassistant.components.homeassistant_alerts import ( + COMPONENT_LOADED_COOLDOWN, + DOMAIN, + UPDATE_INTERVAL, +) from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.setup import ATTR_COMPONENT, async_setup_component from homeassistant.util import dt as dt_util from tests.common import assert_lists_same, async_fire_time_changed, load_fixture @@ -165,6 +170,230 @@ async def test_alerts( } +@pytest.mark.parametrize( + ( + "ha_version", + "supervisor_info", + "initial_components", + "late_components", + "initial_alerts", + "late_alerts", + ), + ( + ( + "2022.7.0", + {"version": "2022.11.0"}, + ["aladdin_connect", "darksky"], + [ + "hassio", + "hikvision", + "hikvisioncam", + "hive", + "homematicip_cloud", + "logi_circle", + "neato", + "nest", + "senseme", + "sochain", + ], + [ + ("aladdin_connect", "aladdin_connect"), + ("dark_sky", "darksky"), + ], + [ + ("aladdin_connect", "aladdin_connect"), + ("dark_sky", "darksky"), + ("hassio", "hassio"), + ("hikvision", "hikvision"), + ("hikvision", "hikvisioncam"), + ("hive_us", "hive"), + ("homematicip_cloud", "homematicip_cloud"), + ("logi_circle", "logi_circle"), + ("neato", "neato"), + ("nest", "nest"), + ("senseme", "senseme"), + ("sochain", "sochain"), + ], + ), + ( + "2022.8.0", + {"version": "2022.11.1"}, + ["aladdin_connect", "darksky"], + [ + "hassio", + "hikvision", + "hikvisioncam", + "hive", + "homematicip_cloud", + "logi_circle", + "neato", + "nest", + "senseme", + "sochain", + ], + [ + ("dark_sky", "darksky"), + ], + [ + ("dark_sky", "darksky"), + ("hikvision", "hikvision"), + ("hikvision", "hikvisioncam"), + ("hive_us", "hive"), + ("homematicip_cloud", "homematicip_cloud"), + ("logi_circle", "logi_circle"), + ("neato", "neato"), + ("nest", "nest"), + ("senseme", "senseme"), + ("sochain", "sochain"), + ], + ), + ( + "2021.10.0", + None, + ["aladdin_connect", "darksky"], + [ + "hikvision", + "hikvisioncam", + "hive", + "homematicip_cloud", + "logi_circle", + "neato", + "nest", + "senseme", + "sochain", + ], + [ + ("aladdin_connect", "aladdin_connect"), + ("dark_sky", "darksky"), + ], + [ + ("aladdin_connect", "aladdin_connect"), + ("dark_sky", "darksky"), + ("hikvision", "hikvision"), + ("hikvision", "hikvisioncam"), + ("homematicip_cloud", "homematicip_cloud"), + ("logi_circle", "logi_circle"), + ("neato", "neato"), + ("nest", "nest"), + ("senseme", "senseme"), + ("sochain", "sochain"), + ], + ), + ), +) +async def test_alerts_refreshed_on_component_load( + hass: HomeAssistant, + hass_ws_client, + aioclient_mock: AiohttpClientMocker, + ha_version, + supervisor_info, + initial_components, + late_components, + initial_alerts, + late_alerts, + freezer, +) -> None: + """Test alerts are refreshed when components are loaded.""" + + aioclient_mock.clear_requests() + aioclient_mock.get( + "https://alerts.home-assistant.io/alerts.json", + text=load_fixture("alerts_1.json", "homeassistant_alerts"), + ) + for alert in initial_alerts: + stub_alert(aioclient_mock, alert[0]) + for alert in late_alerts: + stub_alert(aioclient_mock, alert[0]) + + for domain in initial_components: + hass.config.components.add(domain) + + with patch( + "homeassistant.components.homeassistant_alerts.__version__", + ha_version, + ), patch( + "homeassistant.components.homeassistant_alerts.is_hassio", + return_value=supervisor_info is not None, + ), patch( + "homeassistant.components.homeassistant_alerts.get_supervisor_info", + return_value=supervisor_info, + ): + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "issues": [ + { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "homeassistant_alerts", + "ignored": False, + "is_fixable": False, + "issue_id": f"{alert}.markdown_{integration}", + "issue_domain": integration, + "learn_more_url": None, + "severity": "warning", + "translation_key": "alert", + "translation_placeholders": { + "title": f"Title for {alert}", + "description": f"Content for {alert}", + }, + } + for alert, integration in initial_alerts + ] + } + + with patch( + "homeassistant.components.homeassistant_alerts.__version__", + ha_version, + ), patch( + "homeassistant.components.homeassistant_alerts.is_hassio", + return_value=supervisor_info is not None, + ), patch( + "homeassistant.components.homeassistant_alerts.get_supervisor_info", + return_value=supervisor_info, + ): + # Fake component_loaded events and wait for debounce + for domain in late_components: + hass.config.components.add(domain) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: domain}) + freezer.tick(COMPONENT_LOADED_COOLDOWN + 1) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "issues": [ + { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "homeassistant_alerts", + "ignored": False, + "is_fixable": False, + "issue_id": f"{alert}.markdown_{integration}", + "issue_domain": integration, + "learn_more_url": None, + "severity": "warning", + "translation_key": "alert", + "translation_placeholders": { + "title": f"Title for {alert}", + "description": f"Content for {alert}", + }, + } + for alert, integration in late_alerts + ] + } + + @pytest.mark.parametrize( ("ha_version", "fixture", "expected_alerts"), ( From ea4d2bd1e86fa9afcdeae84c9db6f9815373ed39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Pf=C3=BCtsch?= <54020707+fpfuetsch@users.noreply.github.com> Date: Mon, 6 Mar 2023 10:40:34 +0100 Subject: [PATCH 0257/1058] Sync tado zones after updating climate preset (#79715) --- homeassistant/components/tado/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 9146d4d83d17..691ca639656c 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -268,6 +268,7 @@ class TadoConnector: self.tado.setAway() elif presence == PRESET_HOME: self.tado.setHome() + self.update_zones() def set_zone_overlay( self, From bf5f7c53d8ac56e57e81e5cddf86640df0847c1d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 11:31:50 +0100 Subject: [PATCH 0258/1058] Move mock_setup_entry to conftest (#88484) --- .../templates/config_flow/tests/conftest.py | 14 ++++++++++++++ .../config_flow/tests/test_config_flow.py | 10 +--------- .../templates/config_flow_helper/tests/conftest.py | 14 ++++++++++++++ .../config_flow_helper/tests/test_config_flow.py | 12 ++---------- tests/components/nibe_heatpump/conftest.py | 11 ++++++++++- tests/components/nibe_heatpump/test_config_flow.py | 10 ++-------- tests/components/onewire/conftest.py | 12 +++++++++++- tests/components/onewire/test_config_flow.py | 10 +--------- tests/components/philips_js/conftest.py | 14 +++++++++++++- tests/components/philips_js/test_config_flow.py | 13 ++----------- tests/components/renault/conftest.py | 12 +++++++++++- tests/components/renault/test_config_flow.py | 9 +-------- tests/components/samsungtv/conftest.py | 11 ++++++++++- tests/components/samsungtv/test_config_flow.py | 10 +--------- tests/components/sfr_box/conftest.py | 11 ++++++++++- tests/components/sfr_box/test_config_flow.py | 10 +--------- tests/components/sleepiq/conftest.py | 11 ++++++++++- tests/components/sleepiq/test_config_flow.py | 10 +--------- 18 files changed, 115 insertions(+), 89 deletions(-) create mode 100644 script/scaffold/templates/config_flow/tests/conftest.py create mode 100644 script/scaffold/templates/config_flow_helper/tests/conftest.py diff --git a/script/scaffold/templates/config_flow/tests/conftest.py b/script/scaffold/templates/config_flow/tests/conftest.py new file mode 100644 index 000000000000..dab3d971a3ba --- /dev/null +++ b/script/scaffold/templates/config_flow/tests/conftest.py @@ -0,0 +1,14 @@ +"""Test the NEW_NAME config flow.""" +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.NEW_DOMAIN.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index 6af4bf1e6675..cbc1449378ca 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -1,5 +1,4 @@ """Test the NEW_NAME config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import pytest @@ -10,14 +9,7 @@ from homeassistant.components.NEW_DOMAIN.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: diff --git a/script/scaffold/templates/config_flow_helper/tests/conftest.py b/script/scaffold/templates/config_flow_helper/tests/conftest.py new file mode 100644 index 000000000000..dab3d971a3ba --- /dev/null +++ b/script/scaffold/templates/config_flow_helper/tests/conftest.py @@ -0,0 +1,14 @@ +"""Test the NEW_NAME config flow.""" +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.NEW_DOMAIN.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py index ba7efff5b6c6..d21c66797d86 100644 --- a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py @@ -1,6 +1,5 @@ """Test the NEW_NAME config flow.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest @@ -11,14 +10,7 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize("platform", ("sensor",)) diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index b75c49b2b79e..2a4e2f80ff50 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -1,5 +1,5 @@ """Test configuration for Nibe Heat Pump.""" -from collections.abc import AsyncIterator, Iterable +from collections.abc import AsyncIterator, Generator, Iterable from contextlib import ExitStack from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -10,6 +10,15 @@ from nibe.exceptions import ReadException import pytest +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Make sure we never actually run setup.""" + with patch( + "homeassistant.components.nibe_heatpump.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True, name="mock_connection_constructor") async def fixture_mock_connection_constructor(): """Make sure we have a dummy connection.""" diff --git a/tests/components/nibe_heatpump/test_config_flow.py b/tests/components/nibe_heatpump/test_config_flow.py index 3360c82577fc..22dca1fa2f3d 100644 --- a/tests/components/nibe_heatpump/test_config_flow.py +++ b/tests/components/nibe_heatpump/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Nibe Heat Pump config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock from nibe.coil import Coil from nibe.exceptions import ( @@ -32,13 +32,7 @@ MOCK_FLOW_MODBUS_USERDATA = { } -@pytest.fixture(autouse=True, name="mock_setup_entry") -async def fixture_mock_setup(): - """Make sure we never actually run setup.""" - with patch( - "homeassistant.components.nibe_heatpump.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def _get_connection_form( diff --git a/tests/components/onewire/conftest.py b/tests/components/onewire/conftest.py index 9b8b53859ead..031b29d47a79 100644 --- a/tests/components/onewire/conftest.py +++ b/tests/components/onewire/conftest.py @@ -1,5 +1,6 @@ """Provide common 1-Wire fixtures.""" -from unittest.mock import MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch from pyownet.protocol import ConnError import pytest @@ -14,6 +15,15 @@ from .const import MOCK_OWPROXY_DEVICES from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.onewire.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="device_id", params=MOCK_OWPROXY_DEVICES.keys()) def get_device_id(request: pytest.FixtureRequest) -> str: """Parametrize device id.""" diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index eab44bc5d48c..63e53627e0e2 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for 1-Wire config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from pyownet import protocol @@ -19,14 +18,7 @@ from homeassistant.helpers.config_validation import ensure_list from .const import MOCK_OWPROXY_DEVICES - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.onewire.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture diff --git a/tests/components/philips_js/conftest.py b/tests/components/philips_js/conftest.py index 0aaa76001c1c..bc94d721cc98 100644 --- a/tests/components/philips_js/conftest.py +++ b/tests/components/philips_js/conftest.py @@ -1,5 +1,6 @@ """Standard setup for tests.""" -from unittest.mock import create_autospec, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, create_autospec, patch from haphilipsjs import PhilipsTV import pytest @@ -11,6 +12,17 @@ from . import MOCK_CONFIG, MOCK_ENTITY_ID, MOCK_NAME, MOCK_SERIAL_NO, MOCK_SYSTE from tests.common import MockConfigEntry, mock_device_registry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Disable component setup.""" + with patch( + "homeassistant.components.philips_js.async_setup_entry", return_value=True + ) as mock_setup_entry, patch( + "homeassistant.components.philips_js.async_unload_entry", return_value=True + ): + yield mock_setup_entry + + @pytest.fixture(autouse=True) async def setup_notification(hass): """Configure notification system.""" diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index d4a08aa6886d..1662a2a3fc26 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Philips TV config flow.""" -from unittest.mock import ANY, patch +from unittest.mock import ANY from haphilipsjs import PairingFailure import pytest @@ -19,16 +19,7 @@ from . import ( from tests.common import MockConfigEntry - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def mock_setup_entry_fixture(): - """Disable component setup.""" - with patch( - "homeassistant.components.philips_js.async_setup_entry", return_value=True - ) as mock_setup_entry, patch( - "homeassistant.components.philips_js.async_unload_entry", return_value=True - ): - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.fixture diff --git a/tests/components/renault/conftest.py b/tests/components/renault/conftest.py index 6c62e5d22e2e..312ddbf60924 100644 --- a/tests/components/renault/conftest.py +++ b/tests/components/renault/conftest.py @@ -1,8 +1,9 @@ """Provide common Renault fixtures.""" +from collections.abc import Generator import contextlib from types import MappingProxyType from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from renault_api.kamereon import exceptions, schemas @@ -18,6 +19,15 @@ from .const import MOCK_ACCOUNT_ID, MOCK_CONFIG, MOCK_VEHICLES from tests.common import MockConfigEntry, load_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.renault.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="vehicle_type", params=MOCK_VEHICLES.keys()) def get_vehicle_type(request: pytest.FixtureRequest) -> str: """Parametrize vehicle type.""" diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py index 39d4add062fe..5d933c03c657 100644 --- a/tests/components/renault/test_config_flow.py +++ b/tests/components/renault/test_config_flow.py @@ -21,14 +21,7 @@ from .const import MOCK_CONFIG from tests.common import load_fixture - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> AsyncMock: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.renault.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_config_flow_single_account( diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 73ad642f7e7a..0e95dfa28a9d 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -1,7 +1,7 @@ """Fixtures for Samsung TV.""" from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Generator from datetime import datetime from socket import AddressFamily from typing import Any @@ -25,6 +25,15 @@ import homeassistant.util.dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.samsungtv.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) async def silent_ssdp_scanner(hass): """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index e84b3d30cb1e..ac0072c88ce3 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for Samsung TV config flow.""" -from collections.abc import Generator import socket from unittest.mock import ANY, AsyncMock, Mock, call, patch @@ -216,14 +215,7 @@ DEVICEINFO_WEBSOCKET_NO_SSL = { "timeout": TIMEOUT_WEBSOCKET, } - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.samsungtv.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.usefixtures("remote", "rest_api_failing") diff --git a/tests/components/sfr_box/conftest.py b/tests/components/sfr_box/conftest.py index 207c2939f1d3..1857ffeec303 100644 --- a/tests/components/sfr_box/conftest.py +++ b/tests/components/sfr_box/conftest.py @@ -1,7 +1,7 @@ """Provide common SFR Box fixtures.""" from collections.abc import Generator import json -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from sfrbox_api.models import DslInfo, SystemInfo @@ -14,6 +14,15 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sfr_box.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="config_entry") def get_config_entry(hass: HomeAssistant) -> ConfigEntry: """Create and register mock config entry.""" diff --git a/tests/components/sfr_box/test_config_flow.py b/tests/components/sfr_box/test_config_flow.py index e75e5a42c7d6..c8130d5d6173 100644 --- a/tests/components/sfr_box/test_config_flow.py +++ b/tests/components/sfr_box/test_config_flow.py @@ -1,5 +1,4 @@ """Test the SFR Box config flow.""" -from collections.abc import Generator import json from unittest.mock import AsyncMock, patch @@ -15,14 +14,7 @@ from homeassistant.core import HomeAssistant from tests.common import load_fixture - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.sfr_box.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_config_flow_skip_auth( diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index 23c42ee7f664..9932b75ebdbe 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Generator -from unittest.mock import MagicMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( Side, @@ -40,6 +40,15 @@ SLEEPIQ_CONFIG = { } +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sleepiq.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def mock_bed() -> MagicMock: """Mock a SleepIQBed object with sleepers and lights.""" diff --git a/tests/components/sleepiq/test_config_flow.py b/tests/components/sleepiq/test_config_flow.py index a2191b56583b..0f251675892d 100644 --- a/tests/components/sleepiq/test_config_flow.py +++ b/tests/components/sleepiq/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for the SleepIQ config flow.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from asyncsleepiq import SleepIQLoginException, SleepIQTimeoutException @@ -12,14 +11,7 @@ from homeassistant.core import HomeAssistant from .conftest import SLEEPIQ_CONFIG, setup_platform - -@pytest.fixture(autouse=True, name="mock_setup_entry") -def override_async_setup_entry() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.sleepiq.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_import(hass: HomeAssistant) -> None: From b572ecc62dc427375a6f55d4741df87a411744eb Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 6 Mar 2023 06:06:53 -0500 Subject: [PATCH 0259/1058] Update zwave_js README with contributor instructions (#89158) --- homeassistant/components/zwave_js/README.md | 28 ++++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/README.md b/homeassistant/components/zwave_js/README.md index 920fc4a6a0b6..f82f421f7528 100644 --- a/homeassistant/components/zwave_js/README.md +++ b/homeassistant/components/zwave_js/README.md @@ -1,9 +1,29 @@ -# Z-Wave JS Architecture +# Z-Wave Integration -This document describes the architecture of Z-Wave JS in Home Assistant and how the integration is connected all the way to the Z-Wave USB stick controller. +This document covers details that new contributors may find helpful when getting started. + +## Improving device support + +This section can help new contributors learn how to improve Z-Wave device support within Home Assistant. + +The Z-Wave integration uses a discovery mechanism to create the necessary entities for each of your Z-Wave nodes. To perform this discovery, the integration iterates through each node's [Values](https://zwave-js.github.io/node-zwave-js/#/api/valueid) and compares them to a list of [discovery rules](./discovery.py). If there is a match between a particular discovery rule and the given Value, the integration creates an entity for that value using information sent from the discovery logic to indicate entity platform and instance type. + +In cases where an entity's functionality requires interaction with multiple Values, the discovery rule for that particular entity type is based on the primary Value, or the Value that must be there to indicate that this entity needs to be created, and then the rest of the Values required are discovered by the class instance for that entity. A good example of this is the discovery logic for the `climate` entity. Currently, the discovery logic is tied to the discovery of a Value with a property of `mode` and a command class of `Thermostat Mode`, but the actual entity uses many more Values than that to be fully functional as evident in the [code](./climate.py). + +There are several ways that device support can be improved within Home Assistant, but regardless of the reason, it is important to add device specific tests in these use cases. To do so, add the device's data (from device diagnostics) to the [fixtures folder](../../../tests/components/zwave_js/fixtures) and then define the new fixtures in [conftest.py](../../../tests/components/zwave_js/conftest.py). Use existing tests as the model but the tests can go in the [test_discovery.py module](../../../tests/components/zwave_js/test_discovery.py). + +### Switching HA support for a device from one entity type to another. + +Sometimes manufacturers don't follow the spec properly and implement functionality using the wrong command class, resulting in HA discovering the feature as the wrong entity type. There is a section in the [discovery rules](./discovery.py) for device specific discovery. This can be used to override the type of entity that HA discovers for that particular device's primary Value. + +### Adding feature support to complex entity types + +Sometimes the generic Z-Wave entity logic does not provide all of the features a device is capable of. A great example of this is a climate entity where the current temperature is determined by one of multiple sensors that is configurable by a configuration parameter. In these cases, there is a section in the [discovery rules](./discovery.py) for device specific discovery. By leveraging [discovery_data_template.py](./discovery_data_template.py), it is possible to create the same entity type but with different logic. Generally, we don't like to create entity classes that are device specific, so this mechanism allows us to generalize the implementation. ## Architecture +This section describes the architecture of Z-Wave JS in Home Assistant and how the integration is connected all the way to the Z-Wave USB stick controller. + ### Connection diagram ![alt text][connection_diagram] @@ -24,7 +44,7 @@ Forward the state of Z-Wave JS over a WebSocket connection. Consumes the WebSocket connection and makes the Z-Wave JS state available in Python. -#### Z-Wave JS integration +#### Z-Wave integration Represents Z-Wave devices in Home Assistant and allows control. @@ -38,7 +58,7 @@ Best home automation platform in the world. Z-Wave JS Server can be run as a standalone Node app. -It can also run as part of Z-Wave JS 2 MQTT, which is also a standalone Node app. +It can also run as part of Z-Wave JS UI, which is also a standalone Node app. Both apps are available as Home Assistant add-ons. There are also Docker containers etc. From 14a17b102866397c61d0a02c510c8e0597ce6b36 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 12:28:40 +0100 Subject: [PATCH 0260/1058] Use mock_setup_entry fixture in melnor (#89226) --- tests/components/melnor/conftest.py | 12 +-- tests/components/melnor/test_config_flow.py | 93 +++++++++++---------- 2 files changed, 57 insertions(+), 48 deletions(-) diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index e030e198787a..943018fae881 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -1,6 +1,7 @@ """Tests for the melnor integration.""" from __future__ import annotations +from collections.abc import Generator from unittest.mock import AsyncMock, patch from bleak.backends.device import BLEDevice @@ -141,12 +142,13 @@ def mock_melnor_device(): return device -def patch_async_setup_entry(return_value=True): +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Patch async setup entry to return True.""" - return patch( - "homeassistant.components.melnor.async_setup_entry", - return_value=return_value, - ) + with patch( + "homeassistant.components.melnor.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup # pylint: disable=dangerous-default-value diff --git a/tests/components/melnor/test_config_flow.py b/tests/components/melnor/test_config_flow.py index e0f7a21bff02..95a67644606c 100644 --- a/tests/components/melnor/test_config_flow.py +++ b/tests/components/melnor/test_config_flow.py @@ -1,4 +1,6 @@ """Test the melnor config flow.""" +from unittest.mock import AsyncMock + import pytest import voluptuous as vol @@ -13,15 +15,14 @@ from .conftest import ( FAKE_SERVICE_INFO_1, FAKE_SERVICE_INFO_2, patch_async_discovered_service_info, - patch_async_setup_entry, ) -async def test_user_step_no_devices(hass: HomeAssistant) -> None: +async def test_user_step_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle no devices found.""" - with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info( - [] - ): + with patch_async_discovered_service_info([]): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -30,13 +31,15 @@ async def test_user_step_no_devices(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "no_devices_found" - assert len(mock_setup_entry.mock_calls) == 0 + mock_setup_entry.assert_not_called() -async def test_user_step_discovered_devices(hass: HomeAssistant) -> None: +async def test_user_step_discovered_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we properly handle device picking.""" - with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info(): + with patch_async_discovered_service_info(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, @@ -57,13 +60,15 @@ async def test_user_step_discovered_devices(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1} - assert len(mock_setup_entry.mock_calls) == 1 + mock_setup_entry.assert_called_once() -async def test_user_step_with_existing_device(hass: HomeAssistant) -> None: +async def test_user_step_with_existing_device( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we properly handle device picking.""" - with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info( + with patch_async_discovered_service_info( [FAKE_SERVICE_INFO_1, FAKE_SERVICE_INFO_2] ): # Create the config flow @@ -95,48 +100,50 @@ async def test_user_step_with_existing_device(hass: HomeAssistant) -> None: result["flow_id"], user_input={CONF_ADDRESS: FAKE_ADDRESS_1} ) - assert len(mock_setup_entry.mock_calls) == 0 + mock_setup_entry.assert_not_called() -async def test_bluetooth_discovered(hass: HomeAssistant) -> None: +async def test_bluetooth_discovered( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we short circuit to config entry creation.""" - with patch_async_setup_entry() as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=FAKE_SERVICE_INFO_1, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=FAKE_SERVICE_INFO_1, + ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "bluetooth_confirm" - assert result["description_placeholders"] == {"name": FAKE_ADDRESS_1} + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["description_placeholders"] == {"name": FAKE_ADDRESS_1} - assert len(mock_setup_entry.mock_calls) == 0 + mock_setup_entry.assert_not_called() -async def test_bluetooth_confirm(hass: HomeAssistant) -> None: +async def test_bluetooth_confirm( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we short circuit to config entry creation.""" - with patch_async_setup_entry() as mock_setup_entry: - # Create the config flow - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_BLUETOOTH, - "step_id": "bluetooth_confirm", - "user_input": {CONF_MAC: FAKE_ADDRESS_1}, - }, - data=FAKE_SERVICE_INFO_1, - ) + # Create the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_BLUETOOTH, + "step_id": "bluetooth_confirm", + "user_input": {CONF_MAC: FAKE_ADDRESS_1}, + }, + data=FAKE_SERVICE_INFO_1, + ) - # Interact with it like a user would - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + # Interact with it like a user would + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == FAKE_ADDRESS_1 - assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1} + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == FAKE_ADDRESS_1 + assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1} - assert len(mock_setup_entry.mock_calls) == 1 + mock_setup_entry.assert_called_once() From b2166c3117031b3865ed85255d00bf73b09420d2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Mar 2023 12:42:34 +0100 Subject: [PATCH 0261/1058] Reolink add new number entities (#87932) Co-authored-by: Franck Nijhof --- homeassistant/components/reolink/number.py | 137 ++++++++++++++++++--- 1 file changed, 123 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index c1baf4b156f6..05956aff3556 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -13,6 +13,7 @@ from homeassistant.components.number import ( NumberMode, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,10 +26,8 @@ from .entity import ReolinkCoordinatorEntity class ReolinkNumberEntityDescriptionMixin: """Mixin values for Reolink number entities.""" - value: Callable[[Host, int | None], bool] - get_min_value: Callable[[Host, int | None], float] - get_max_value: Callable[[Host, int | None], float] - method: Callable[[Host, int | None, float], Any] + value: Callable[[Host, int], float] + method: Callable[[Host, int, float], Any] @dataclass @@ -38,7 +37,9 @@ class ReolinkNumberEntityDescription( """A class that describes number entities.""" mode: NumberMode = NumberMode.AUTO - supported: Callable[[Host, int | None], bool] = lambda api, ch: True + supported: Callable[[Host, int], bool] = lambda api, ch: True + get_min_value: Callable[[Host, int], float] | None = None + get_max_value: Callable[[Host, int], float] | None = None NUMBER_ENTITIES = ( @@ -50,7 +51,7 @@ NUMBER_ENTITIES = ( native_step=1, get_min_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["min"], get_max_value=lambda api, ch: api.zoom_range(ch)["zoom"]["pos"]["max"], - supported=lambda api, ch: api.zoom_supported(ch), + supported=lambda api, ch: api.supported(ch, "zoom"), value=lambda api, ch: api.get_zoom(ch), method=lambda api, ch, value: api.set_zoom(ch, int(value)), ), @@ -62,10 +63,115 @@ NUMBER_ENTITIES = ( native_step=1, get_min_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["min"], get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"], - supported=lambda api, ch: api.zoom_supported(ch), + supported=lambda api, ch: api.supported(ch, "zoom"), value=lambda api, ch: api.get_focus(ch), method=lambda api, ch, value: api.set_zoom(ch, int(value)), ), + ReolinkNumberEntityDescription( + key="floodlight_brightness", + name="Floodlight turn on brightness", + icon="mdi:spotlight-beam", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "floodLight"), + value=lambda api, ch: api.whiteled_brightness(ch), + method=lambda api, ch, value: api.set_whiteled(ch, brightness=int(value)), + ), + ReolinkNumberEntityDescription( + key="volume", + name="Volume", + icon="mdi:volume-high", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "volume"), + value=lambda api, ch: api.volume(ch), + method=lambda api, ch, value: api.set_volume(ch, volume=int(value)), + ), + ReolinkNumberEntityDescription( + key="guard_return_time", + name="Guard return time", + icon="mdi:crosshairs-gps", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=10, + native_max_value=300, + supported=lambda api, ch: api.supported(ch, "ptz_guard"), + value=lambda api, ch: api.ptz_guard_time(ch), + method=lambda api, ch, value: api.set_ptz_guard(ch, time=int(value)), + ), + ReolinkNumberEntityDescription( + key="motion_sensitivity", + name="Motion sensitivity", + icon="mdi:motion-sensor", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=50, + supported=lambda api, ch: api.supported(ch, "md_sensitivity"), + value=lambda api, ch: api.md_sensitivity(ch), + method=lambda api, ch, value: api.set_md_sensitivity(ch, int(value)), + ), + ReolinkNumberEntityDescription( + key="ai_face_sensititvity", + name="AI face sensitivity", + icon="mdi:face-recognition", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "face") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "face"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "face"), + ), + ReolinkNumberEntityDescription( + key="ai_person_sensititvity", + name="AI person sensitivity", + icon="mdi:account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "people") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "people"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "people"), + ), + ReolinkNumberEntityDescription( + key="ai_vehicle_sensititvity", + name="AI vehicle sensitivity", + icon="mdi:car", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "vehicle") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "vehicle"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "vehicle"), + ), + ReolinkNumberEntityDescription( + key="ai_pet_sensititvity", + name="AI pet sensitivity", + icon="mdi:dog-side", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: ( + api.supported(ch, "ai_sensitivity") and api.ai_supported(ch, "dog_cat") + ), + value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), + method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), + ), ) @@ -100,15 +206,17 @@ class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): super().__init__(reolink_data, channel) self.entity_description = entity_description - self._attr_native_min_value = self.entity_description.get_min_value( - self._host.api, self._channel - ) - self._attr_native_max_value = self.entity_description.get_max_value( - self._host.api, self._channel - ) + if entity_description.get_min_value is not None: + self._attr_native_min_value = entity_description.get_min_value( + self._host.api, channel + ) + if entity_description.get_max_value is not None: + self._attr_native_max_value = entity_description.get_max_value( + self._host.api, channel + ) self._attr_mode = entity_description.mode self._attr_unique_id = ( - f"{self._host.unique_id}_{self._channel}_{entity_description.key}" + f"{self._host.unique_id}_{channel}_{entity_description.key}" ) @property @@ -119,3 +227,4 @@ class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" await self.entity_description.method(self._host.api, self._channel, value) + self.async_write_ha_state() From 0ce9c6293a0bd2e8d557ce86ad7de56e7ee1a7eb Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 6 Mar 2023 12:47:01 +0100 Subject: [PATCH 0262/1058] Update frontend to 20230306.0 (#89227) --- 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 c09f2d501c62..da68e48cc087 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==20230302.0"] + "requirements": ["home-assistant-frontend==20230306.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d72bea19837e..5ce52033640d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230302.0 +home-assistant-frontend==20230306.0 home-assistant-intents==2023.2.28 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index cc83b2eab038..1a948b2d7d93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230302.0 +home-assistant-frontend==20230306.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f4b0c06bc6d..ebcac904941f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230302.0 +home-assistant-frontend==20230306.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From 76cc4c9c086da9dcd813fe1bc02a6fe1bbcee62f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Mar 2023 12:48:36 +0100 Subject: [PATCH 0263/1058] Add Reolink light platform (#88619) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/reolink/__init__.py | 1 + homeassistant/components/reolink/light.py | 157 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 homeassistant/components/reolink/light.py diff --git a/.coveragerc b/.coveragerc index e44ca0d70dc8..f38c6226ac82 100644 --- a/.coveragerc +++ b/.coveragerc @@ -981,6 +981,7 @@ omit = homeassistant/components/reolink/camera.py homeassistant/components/reolink/entity.py homeassistant/components/reolink/host.py + homeassistant/components/reolink/light.py homeassistant/components/reolink/number.py homeassistant/components/reolink/select.py homeassistant/components/reolink/siren.py diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index bed286c3bf40..94d4c1561ab8 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -27,6 +27,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, + Platform.LIGHT, Platform.NUMBER, Platform.SELECT, Platform.SIREN, diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py new file mode 100644 index 000000000000..dd71f91bb0ba --- /dev/null +++ b/homeassistant/components/reolink/light.py @@ -0,0 +1,157 @@ +"""Component providing support for Reolink light entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from reolink_aio.api import Host + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, + LightEntityDescription, +) +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 ReolinkData +from .const import DOMAIN +from .entity import ReolinkCoordinatorEntity + + +@dataclass +class ReolinkLightEntityDescriptionMixin: + """Mixin values for Reolink light entities.""" + + is_on_fn: Callable[[Host, int], bool] + turn_on_off_fn: Callable[[Host, int, bool], Any] + + +@dataclass +class ReolinkLightEntityDescription( + LightEntityDescription, ReolinkLightEntityDescriptionMixin +): + """A class that describes light entities.""" + + supported_fn: Callable[[Host, int], bool] = lambda api, ch: True + get_brightness_fn: Callable[[Host, int], int] | None = None + set_brightness_fn: Callable[[Host, int, float], Any] | None = None + + +LIGHT_ENTITIES = ( + ReolinkLightEntityDescription( + key="floodlight", + name="Floodlight", + icon="mdi:spotlight-beam", + supported_fn=lambda api, ch: api.supported(ch, "floodLight"), + is_on_fn=lambda api, ch: api.whiteled_state(ch), + turn_on_off_fn=lambda api, ch, value: api.set_whiteled(ch, state=value), + get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch), + set_brightness_fn=lambda api, ch, value: api.set_whiteled(ch, brightness=value), + ), + ReolinkLightEntityDescription( + key="ir_lights", + name="Infra red lights in night mode", + icon="mdi:led-off", + supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), + is_on_fn=lambda api, ch: api.ir_enabled(ch), + turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), + ), + ReolinkLightEntityDescription( + key="status_led", + name="Status LED", + icon="mdi:lightning-bolt-circle", + entity_category=EntityCategory.CONFIG, + supported_fn=lambda api, ch: api.supported(ch, "status_led"), + is_on_fn=lambda api, ch: api.status_led_enabled(ch), + turn_on_off_fn=lambda api, ch, value: api.set_status_led(ch, value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink light entities.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkLightEntity(reolink_data, channel, entity_description) + for entity_description in LIGHT_ENTITIES + for channel in reolink_data.host.api.channels + if entity_description.supported_fn(reolink_data.host.api, channel) + ) + + +class ReolinkLightEntity(ReolinkCoordinatorEntity, LightEntity): + """Base light entity class for Reolink IP cameras.""" + + entity_description: ReolinkLightEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + channel: int, + entity_description: ReolinkLightEntityDescription, + ) -> None: + """Initialize Reolink light entity.""" + super().__init__(reolink_data, channel) + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{entity_description.key}" + ) + + if entity_description.set_brightness_fn is None: + self._attr_supported_color_modes = {ColorMode.ONOFF} + self._attr_color_mode = ColorMode.ONOFF + else: + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + self._attr_color_mode = ColorMode.BRIGHTNESS + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.entity_description.is_on_fn(self._host.api, self._channel) + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0.255.""" + if self.entity_description.get_brightness_fn is None: + return None + + return round( + 255 + * ( + self.entity_description.get_brightness_fn(self._host.api, self._channel) + / 100.0 + ) + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, False + ) + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn light on.""" + if ( + brightness := kwargs.get(ATTR_BRIGHTNESS) + ) is not None and self.entity_description.set_brightness_fn is not None: + brightness_pct = int(brightness / 255.0 * 100) + await self.entity_description.set_brightness_fn( + self._host.api, self._channel, brightness_pct + ) + + await self.entity_description.turn_on_off_fn( + self._host.api, self._channel, True + ) + self.async_write_ha_state() From 0c042e8f7253becf47728a830ca75e32511d9603 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Mar 2023 14:04:36 +0100 Subject: [PATCH 0264/1058] Fix conditional check (#89231) --- homeassistant/components/konnected/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index bd629d53fc61..119c7c946a5f 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -84,7 +84,7 @@ def ensure_zone(value): if value is None: raise vol.Invalid("zone value is None") - if str(value) not in ZONES is None: + if str(value) not in ZONES: raise vol.Invalid("zone not valid") return str(value) From 5ee383456f67b38e8efacfe383cac2876ef77b24 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 6 Mar 2023 15:34:47 +0100 Subject: [PATCH 0265/1058] Catch exceptions and add logging when writing states on MQTT entities (#89091) * Catch exceptions when writing states * Do not use wrapper for logging and adjust tests * Catch logging directly on async_write_ha_state() * Update homeassistant/components/mqtt/models.py Co-authored-by: Erik Montnemery * Fix test --------- Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/client.py | 2 +- homeassistant/components/mqtt/models.py | 16 +++++++-- tests/components/mqtt/test_init.py | 44 +++++++++++++++++++++++++ tests/components/mqtt/test_text.py | 35 ++++++++++++++------ 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index ad89a35ec0a0..e717da5144c3 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -719,7 +719,7 @@ class MQTT: timestamp, ), ) - self._mqtt_data.state_write_requests.process_write_state_requests() + self._mqtt_data.state_write_requests.process_write_state_requests(msg) def _mqtt_on_callback( self, diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index a88fb97b8334..84735c55e08e 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -21,6 +21,8 @@ from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType if TYPE_CHECKING: + from paho.mqtt.client import MQTTMessage + from .client import MQTT, Subscription from .debug_info import TimestampedPublishMessage from .device_trigger import Trigger @@ -260,11 +262,21 @@ class EntityTopicState: self.subscribe_calls: dict[str, Entity] = {} @callback - def process_write_state_requests(self) -> None: + def process_write_state_requests(self, msg: MQTTMessage) -> None: """Process the write state requests.""" while self.subscribe_calls: _, entity = self.subscribe_calls.popitem() - entity.async_write_ha_state() + try: + entity.async_write_ha_state() + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Exception raised when updating state of %s, topic: " + "'%s' with payload: %s", + entity.entity_id, + msg.topic, + msg.payload, + exc_info=True, + ) @callback def write_state_request(self, entity: Entity) -> None: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 47f8743f5026..8c6ee21c932b 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -817,6 +817,50 @@ def test_entity_device_info_schema() -> None: ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "sensor": [ + { + "name": "test-sensor", + "unique_id": "test-sensor", + "state_topic": "test/state", + } + ] + } + } + ], +) +async def test_handle_logging_on_writing_the_entity_state( + hass: HomeAssistant, + mock_hass_config: None, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test on log handling when an error occurs writing the state.""" + await mqtt_mock_entry_no_yaml_config() + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test/state", b"initial_state") + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == "initial_state" + with patch( + "homeassistant.helpers.entity.Entity.async_write_ha_state", + side_effect=ValueError("Invalid value for sensor"), + ): + async_fire_mqtt_message(hass, "test/state", b"payload causing errors") + await hass.async_block_till_done() + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == "initial_state" + assert "Invalid value for sensor" in caplog.text + assert "Exception raised when updating state of" in caplog.text + + async def test_receiving_non_utf8_message_gets_logged( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 93b605d4a33b..a5209e3f5fd5 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -116,7 +116,9 @@ async def test_controlling_state_via_topic( async def test_controlling_validation_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the validation of a received state.""" assert await async_setup_component( @@ -148,26 +150,39 @@ async def test_controlling_validation_state_via_topic( assert state.state == "yes" # test pattern error - with pytest.raises(ValueError): - async_fire_mqtt_message(hass, "state-topic", "other") - await hass.async_block_till_done() + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "other") + await hass.async_block_till_done() + assert ( + "ValueError: Entity text.test provides state other which does not match expected pattern (y|n)" + in caplog.text + ) state = hass.states.get("text.test") assert state.state == "yes" # test text size to large - with pytest.raises(ValueError): - async_fire_mqtt_message(hass, "state-topic", "yesyesyesyes") - await hass.async_block_till_done() + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "yesyesyesyes") + await hass.async_block_till_done() + assert ( + "ValueError: Entity text.test provides state yesyesyesyes which is too long (maximum length 10)" + in caplog.text + ) state = hass.states.get("text.test") assert state.state == "yes" # test text size to small - with pytest.raises(ValueError): - async_fire_mqtt_message(hass, "state-topic", "y") - await hass.async_block_till_done() + caplog.clear() + async_fire_mqtt_message(hass, "state-topic", "y") + await hass.async_block_till_done() + assert ( + "ValueError: Entity text.test provides state y which is too short (minimum length 2)" + in caplog.text + ) state = hass.states.get("text.test") assert state.state == "yes" + # test with valid text async_fire_mqtt_message(hass, "state-topic", "no") await hass.async_block_till_done() state = hass.states.get("text.test") From ee6f969c2a4675ccedd04afda36f841bb652b99b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 15:56:34 +0100 Subject: [PATCH 0266/1058] Add type hints to ps4 media player (#89236) --- homeassistant/components/ps4/media_player.py | 94 ++++++++++++-------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 3e6a15df340d..8799aad65d47 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -2,6 +2,7 @@ import asyncio from contextlib import suppress import logging +from typing import Any, cast from pyps4_2ndscreen.errors import NotReady, PSDataIncomplete from pyps4_2ndscreen.media_art import TYPE_APP as PS_TYPE_APP @@ -27,6 +28,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.json import JsonObjectType from . import format_unique_id, load_games, save_games from .const import ( @@ -52,12 +54,12 @@ async def async_setup_entry( ) -> None: """Set up PS4 from a config entry.""" config = config_entry - creds = config.data[CONF_TOKEN] + creds: str = config.data[CONF_TOKEN] device_list = [] for device in config.data["devices"]: - host = device[CONF_HOST] - region = device[CONF_REGION] - name = device[CONF_NAME] + host: str = device[CONF_HOST] + region: str = device[CONF_REGION] + name: str = device[CONF_NAME] ps4 = pyps4.Ps4Async(host, creds, device_name=DEFAULT_ALIAS) device_list.append(PS4Device(config, name, host, region, ps4, creds)) async_add_entities(device_list, update_before_add=True) @@ -75,7 +77,15 @@ class PS4Device(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, config, name, host, region, ps4, creds): + def __init__( + self, + config: ConfigEntry, + name: str, + host: str, + region: str, + ps4: pyps4.Ps4Async, + creds: str, + ) -> None: """Initialize the ps4 device.""" self._entry_id = config.entry_id self._ps4 = ps4 @@ -83,30 +93,30 @@ class PS4Device(MediaPlayerEntity): self._attr_name = name self._region = region self._creds = creds - self._media_image = None - self._games = {} + self._media_image: str | None = None + self._games: JsonObjectType = {} self._retry = 0 self._disconnected = False @callback - def status_callback(self): + def status_callback(self) -> None: """Handle status callback. Parse status.""" self._parse_status() self.async_write_ha_state() @callback - def subscribe_to_protocol(self): + def subscribe_to_protocol(self) -> None: """Notify protocol to callback with update changes.""" self.hass.data[PS4_DATA].protocol.add_callback(self._ps4, self.status_callback) @callback - def unsubscribe_to_protocol(self): + def unsubscribe_to_protocol(self) -> None: """Notify protocol to remove callback.""" self.hass.data[PS4_DATA].protocol.remove_callback( self._ps4, self.status_callback ) - def check_region(self): + def check_region(self) -> None: """Display logger msg if region is deprecated.""" # Non-Breaking although data returned may be inaccurate. if self._region in deprecated_regions: @@ -151,10 +161,11 @@ class PS4Device(MediaPlayerEntity): self._parse_status() - def _parse_status(self): + def _parse_status(self) -> None: """Parse status.""" - if (status := self._ps4.status) is not None: - self._games = load_games(self.hass, self.unique_id) + status: dict[str, Any] | None = self._ps4.status + if status is not None: + self._games = load_games(self.hass, cast(str, self.unique_id)) if self._games: self.get_source_list() @@ -193,28 +204,30 @@ class PS4Device(MediaPlayerEntity): def _use_saved(self) -> bool: """Return True, Set media attrs if data is locked.""" if self.media_content_id in self._games: - store = self._games[self.media_content_id] + store = cast(JsonObjectType, self._games[self.media_content_id]) # If locked get attributes from file. if store.get(ATTR_LOCKED): - self._attr_media_title = store.get(ATTR_MEDIA_TITLE) + self._attr_media_title = cast(str | None, store.get(ATTR_MEDIA_TITLE)) self._attr_source = self._attr_media_title - self._media_image = store.get(ATTR_MEDIA_IMAGE_URL) - self._attr_media_content_type = store.get(ATTR_MEDIA_CONTENT_TYPE) + self._media_image = cast(str | None, store.get(ATTR_MEDIA_IMAGE_URL)) + self._attr_media_content_type = cast( + str | None, store.get(ATTR_MEDIA_CONTENT_TYPE) + ) return True return False - def idle(self): + def idle(self) -> None: """Set states for state idle.""" self.reset_title() self._attr_state = MediaPlayerState.IDLE - def state_standby(self): + def state_standby(self) -> None: """Set states for state standby.""" self.reset_title() self._attr_state = MediaPlayerState.STANDBY - def state_unknown(self): + def state_unknown(self) -> None: """Set states for state unknown.""" self.reset_title() self._attr_state = None @@ -223,14 +236,14 @@ class PS4Device(MediaPlayerEntity): self._disconnected = True self._retry = 0 - def reset_title(self): + def reset_title(self) -> None: """Update if there is no title.""" self._attr_media_title = None self._attr_media_content_id = None self._attr_media_content_type = None self._attr_source = None - async def async_get_title_data(self, title_id, name): + async def async_get_title_data(self, title_id: str, name: str) -> None: """Get PS Store Data.""" app_name = None @@ -272,10 +285,10 @@ class PS4Device(MediaPlayerEntity): await self.hass.async_add_executor_job(self.update_list) self.async_write_ha_state() - def update_list(self): + def update_list(self) -> None: """Update Game List, Correct data if different.""" if self.media_content_id in self._games: - store = self._games[self.media_content_id] + store = cast(JsonObjectType, self._games[self.media_content_id]) if ( store.get(ATTR_MEDIA_TITLE) != self.media_title @@ -290,7 +303,7 @@ class PS4Device(MediaPlayerEntity): self._media_image, self._attr_media_content_type, ) - self._games = load_games(self.hass, self.unique_id) + self._games = load_games(self.hass, cast(str, self.unique_id)) self.get_source_list() @@ -298,14 +311,22 @@ class PS4Device(MediaPlayerEntity): """Parse data entry and update source list.""" games = [] for data in self._games.values(): - games.append(data[ATTR_MEDIA_TITLE]) + data = cast(JsonObjectType, data) + games.append(cast(str, data[ATTR_MEDIA_TITLE])) self._attr_source_list = sorted(games) - def add_games(self, title_id, app_name, image, g_type, is_locked=False): + def add_games( + self, + title_id: str | None, + app_name: str | None, + image: str | None, + g_type: str | None, + is_locked: bool = False, + ) -> None: """Add games to list.""" games = self._games if title_id is not None and title_id not in games: - game = { + game: JsonObjectType = { title_id: { ATTR_MEDIA_TITLE: app_name, ATTR_MEDIA_IMAGE_URL: image, @@ -314,9 +335,9 @@ class PS4Device(MediaPlayerEntity): } } games.update(game) - save_games(self.hass, games, self.unique_id) + save_games(self.hass, games, cast(str, self.unique_id)) - async def async_get_device_info(self, status): + async def async_get_device_info(self, status: dict[str, Any] | None) -> None: """Set device info for registry.""" # If cannot get status on startup, assume info from registry. if status is None: @@ -362,7 +383,7 @@ class PS4Device(MediaPlayerEntity): self.hass.data[PS4_DATA].devices.remove(self) @property - def entity_picture(self): + def entity_picture(self) -> str | None: """Return picture.""" if ( self.state == MediaPlayerState.PLAYING @@ -376,7 +397,7 @@ class PS4Device(MediaPlayerEntity): return None @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" if self.media_content_id is None: return None @@ -405,7 +426,8 @@ class PS4Device(MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" for title_id, data in self._games.items(): - game = data[ATTR_MEDIA_TITLE] + data = cast(JsonObjectType, data) + game = cast(str, data[ATTR_MEDIA_TITLE]) if ( source.lower().encode(encoding="utf-8") == game.lower().encode(encoding="utf-8") @@ -421,10 +443,10 @@ class PS4Device(MediaPlayerEntity): _LOGGER.warning("Could not start title. '%s' is not in source list", source) return - async def async_send_command(self, command): + async def async_send_command(self, command: str) -> None: """Send Button Command.""" await self.async_send_remote_control(command) - async def async_send_remote_control(self, command): + async def async_send_remote_control(self, command: str) -> None: """Send RC command.""" await self._ps4.remote_control(command) From 9ff45ca01340804da331bb5629a5e17d4e2cce26 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 6 Mar 2023 16:08:14 +0100 Subject: [PATCH 0267/1058] Allow loading UniFi entities on config options change (#88762) Co-authored-by: Franck Nijhof --- homeassistant/components/unifi/controller.py | 18 +++++- homeassistant/components/unifi/entity.py | 9 +++ tests/components/unifi/test_device_tracker.py | 21 ++++++- tests/components/unifi/test_sensor.py | 62 +++++++++++++++++++ 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 31d07278920f..69c3cc780597 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -31,7 +31,10 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_entries_for_config_entry from homeassistant.helpers.event import async_track_time_interval @@ -108,6 +111,7 @@ class UniFiController: self.load_config_entry_options() self.entities = {} + self.known_objects: set[tuple[str, str]] = set() def load_config_entry_options(self): """Store attributes to avoid property call overhead since they are called frequently.""" @@ -207,6 +211,7 @@ class UniFiController: [ unifi_platform_entity(obj_id, self, description) for obj_id in obj_ids + if (description.key, obj_id) not in self.known_objects if description.allowed_fn(self, obj_id) if description.supported_fn(self, obj_id) ] @@ -221,6 +226,17 @@ class UniFiController: api_handler.subscribe(async_create_entity, ItemEvent.ADDED) + @callback + def async_options_updated() -> None: + """Load new entities based on changed options.""" + async_add_unifi_entity(list(api_handler)) + + self.config_entry.async_on_unload( + async_dispatcher_connect( + self.hass, self.signal_options_update, async_options_updated + ) + ) + for description in descriptions: async_load_entities(description) diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 783950310e4f..5d763ecfe8ad 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -103,6 +103,8 @@ class UnifiEntity(Entity, Generic[HandlerT, DataT]): self.controller = controller self.entity_description = description + controller.known_objects.add((description.key, obj_id)) + self._removed = False self._attr_available = description.available_fn(controller, obj_id) @@ -118,6 +120,13 @@ class UnifiEntity(Entity, Generic[HandlerT, DataT]): description = self.entity_description handler = description.api_handler_fn(self.controller.api) + @callback + def unregister_object() -> None: + """Remove object ID from known_objects when unloaded.""" + self.controller.known_objects.discard((description.key, self._obj_id)) + + self.async_on_remove(unregister_object) + # New data from handler self.async_on_remove( handler.subscribe( diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index f271394df1c8..5dcf1fc69328 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -635,6 +635,15 @@ async def test_option_track_devices( assert hass.states.get("device_tracker.client") assert not hass.states.get("device_tracker.device") + hass.config_entries.async_update_entry( + config_entry, + options={CONF_TRACK_DEVICES: True}, + ) + await hass.async_block_till_done() + + assert hass.states.get("device_tracker.client") + assert hass.states.get("device_tracker.device") + async def test_option_ssid_filter( hass: HomeAssistant, @@ -1041,7 +1050,7 @@ async def test_dont_track_devices( "version": "4.0.42.10433", } - await setup_unifi_integration( + config_entry = await setup_unifi_integration( hass, aioclient_mock, options={CONF_TRACK_DEVICES: False}, @@ -1053,6 +1062,16 @@ async def test_dont_track_devices( assert hass.states.get("device_tracker.client") assert not hass.states.get("device_tracker.device") + hass.config_entries.async_update_entry( + config_entry, + options={CONF_TRACK_DEVICES: True}, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2 + assert hass.states.get("device_tracker.client") + assert hass.states.get("device_tracker.device") + async def test_dont_track_wired_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_device_registry diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index b5546b72bdde..18007998ebab 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -14,11 +14,13 @@ from homeassistant.components.unifi.const import ( CONF_ALLOW_UPTIME_SENSORS, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, + DOMAIN as UNIFI_DOMAIN, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util @@ -183,6 +185,37 @@ async def test_bandwidth_sensors( assert hass.states.get("sensor.wired_client_rx") is None assert hass.states.get("sensor.wired_client_tx") is None + # Enable option + + options[CONF_ALLOW_BANDWIDTH_SENSORS] = True + hass.config_entries.async_update_entry(config_entry, options=options.copy()) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 5 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + assert hass.states.get("sensor.wireless_client_rx") + assert hass.states.get("sensor.wireless_client_tx") + assert hass.states.get("sensor.wired_client_rx") + assert hass.states.get("sensor.wired_client_tx") + + # Try to add the sensors again, using a signal + + clients_connected = {wired_client["mac"], wireless_client["mac"]} + devices_connected = set() + + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + async_dispatcher_send( + hass, + controller.signal_update, + clients_connected, + devices_connected, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 5 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + @pytest.mark.parametrize( ("initial_uptime", "event_uptime", "new_uptime"), @@ -267,6 +300,35 @@ async def test_uptime_sensors( assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 assert hass.states.get("sensor.client1_uptime") is None + # Enable option + + options[CONF_ALLOW_UPTIME_SENSORS] = True + with patch("homeassistant.util.dt.now", return_value=now): + hass.config_entries.async_update_entry(config_entry, options=options.copy()) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert hass.states.get("sensor.client1_uptime") + + # Try to add the sensors again, using a signal + + clients_connected = {uptime_client["mac"]} + devices_connected = set() + + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + async_dispatcher_send( + hass, + controller.signal_update, + clients_connected, + devices_connected, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + async def test_remove_sensors( hass: HomeAssistant, From e8bdaaacd9e893b226b6bb7057ab88d83c8b920d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Mar 2023 16:08:53 +0100 Subject: [PATCH 0268/1058] Add comment about Reolink Floodlight turn on brightness (#89234) Co-authored-by: Martin Hjelmare --- homeassistant/components/reolink/number.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 05956aff3556..82c1924e27db 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -67,6 +67,9 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.get_focus(ch), method=lambda api, ch, value: api.set_zoom(ch, int(value)), ), + # "Floodlight turn on brightness" controls the brightness of the floodlight when + # it is turned on internally by the camera (see "select.floodlight_mode" entity) + # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", name="Floodlight turn on brightness", From 91e389c58d0bc2d2846773369eeaf3433160729f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 6 Mar 2023 16:16:31 +0100 Subject: [PATCH 0269/1058] Bump ruff to 0.0.253 (#89211) Co-authored-by: Paulus Schoutsen --- .pre-commit-config.yaml | 2 +- homeassistant/components/ps4/media_player.py | 5 ++++- homeassistant/components/zha/core/gateway.py | 5 ++++- requirements_test_pre_commit.txt | 2 +- tests/test_runner.py | 13 +++++-------- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab481ac4eaf0..357e2663fcc8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.247 + rev: v0.0.253 hooks: - id: ruff args: diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 8799aad65d47..23438dd80c47 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -188,7 +188,10 @@ class PS4Device(MediaPlayerEntity): self._attr_source = self._attr_media_title self._attr_media_content_type = None # Get data from PS Store. - asyncio.ensure_future(self.async_get_title_data(title_id, name)) + self.hass.async_create_background_task( + self.async_get_title_data(title_id, name), + "ps4.media_player-get_title_data", + ) else: if self.state != MediaPlayerState.IDLE: self.idle() diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 2f1b22e0ea2d..1bc77d3f3608 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -393,7 +393,10 @@ class ZHAGateway: device_info = zha_device.zha_device_info zha_device.async_cleanup_handles() async_dispatcher_send(self._hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") - asyncio.ensure_future(self._async_remove_device(zha_device, entity_refs)) + self._hass.async_create_task( + self._async_remove_device(zha_device, entity_refs), + "ZHAGateway._async_remove_device", + ) if device_info is not None: async_dispatcher_send( self._hass, diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 863b61afe5f7..e2deb067d7d4 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -14,5 +14,5 @@ pycodestyle==2.10.0 pydocstyle==6.2.3 pyflakes==3.0.1 pyupgrade==3.3.1 -ruff==0.0.247 +ruff==0.0.253 yamllint==1.28.0 diff --git a/tests/test_runner.py b/tests/test_runner.py index 25b75b94c3b4..e4af1df2b800 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -84,11 +84,9 @@ def test_run_does_not_block_forever_with_shielded_task( """Test we can shutdown and not block forever.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) - created_tasks = False + tasks = [] async def _async_create_tasks(*_): - nonlocal created_tasks - async def async_raise(*_): try: await asyncio.sleep(2) @@ -101,11 +99,10 @@ def test_run_does_not_block_forever_with_shielded_task( except asyncio.CancelledError: await asyncio.sleep(2) - asyncio.ensure_future(asyncio.shield(async_shielded())) - asyncio.ensure_future(asyncio.sleep(2)) - asyncio.ensure_future(async_raise()) + tasks.append(asyncio.ensure_future(asyncio.shield(async_shielded()))) + tasks.append(asyncio.ensure_future(asyncio.sleep(2))) + tasks.append(asyncio.ensure_future(async_raise())) await asyncio.sleep(0.1) - created_tasks = True return 0 with patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), patch( @@ -115,7 +112,7 @@ def test_run_does_not_block_forever_with_shielded_task( ): runner.run(default_config) - assert created_tasks is True + assert len(tasks) == 3 assert ( "Task could not be canceled and was still running after shutdown" in caplog.text ) From f9be796ca3483ba6bf44c1c82e08c1a4eda48ab0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 6 Mar 2023 17:23:24 +0100 Subject: [PATCH 0270/1058] Reolink extend DHCP discovery (#89238) --- homeassistant/components/reolink/manifest.json | 7 ++++++- homeassistant/generated/dhcp.py | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 978b38daeae4..5cb7530ec8e7 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -6,8 +6,13 @@ "dependencies": ["webhook"], "dhcp": [ { - "hostname": "reolink*", + "hostname": "reolink*" + }, + { "macaddress": "EC71DB*" + }, + { + "registered_devices": true } ], "documentation": "https://www.home-assistant.io/integrations/reolink", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 5be29e022f13..333db76d4f36 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -387,8 +387,15 @@ DHCP: list[dict[str, str | bool]] = [ { "domain": "reolink", "hostname": "reolink*", + }, + { + "domain": "reolink", "macaddress": "EC71DB*", }, + { + "domain": "reolink", + "registered_devices": True, + }, { "domain": "ring", "hostname": "ring*", From b407227d4a735d5337f3cf7a8d04a13fd82b8def Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Mar 2023 17:50:42 +0100 Subject: [PATCH 0271/1058] Update pylint to 2.16.4 (#89240) --- requirements_test.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 7f2e51a0c08d..9e6ded1f3953 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.14.1 +astroid==2.14.2 codecov==2.1.12 coverage==7.2.1 freezegun==1.2.2 @@ -15,7 +15,7 @@ mock-open==1.4.0 mypy==1.0.1 pre-commit==3.1.0 pydantic==1.10.5 -pylint==2.16.0 +pylint==2.16.4 pylint-per-file-ignores==1.1.0 pipdeptree==2.5.0 pytest-asyncio==0.20.3 From 1538f639ae8082067548bc0ecb5311cd7ad8e6d1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 6 Mar 2023 18:12:19 +0100 Subject: [PATCH 0272/1058] Bump `gios` library to version 3.1.0 (#89044) --- homeassistant/components/gios/__init__.py | 10 ++----- homeassistant/components/gios/config_flow.py | 4 +-- homeassistant/components/gios/manifest.json | 2 +- homeassistant/components/gios/sensor.py | 4 +++ homeassistant/components/gios/strings.json | 14 +++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../gios/fixtures/diagnostics_data.json | 30 +++++++++---------- tests/components/gios/fixtures/indexes.json | 16 +++++----- tests/components/gios/test_sensor.py | 27 +++++++++++------ 10 files changed, 67 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 1ade1a83cc7c..4aad3b053709 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -7,7 +7,8 @@ from typing import Any, cast from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout -from gios import ApiError, Gios, InvalidSensorsData, NoStationError +from gios import Gios +from gios.exceptions import GiosError from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -89,10 +90,5 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator): try: async with timeout(API_TIMEOUT): return cast(dict[str, Any], await self.gios.async_update()) - except ( - ApiError, - NoStationError, - ClientConnectorError, - InvalidSensorsData, - ) as error: + except (GiosError, ClientConnectorError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index 0fa5052e1291..a1b4abd2dc79 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -6,7 +6,7 @@ from typing import Any from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout -from gios import ApiError, Gios, InvalidSensorsData, NoStationError +from gios import ApiError, Gios, InvalidSensorsDataError, NoStationError import voluptuous as vol from homeassistant import config_entries @@ -50,7 +50,7 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except NoStationError: errors[CONF_STATION_ID] = "wrong_station_id" - except InvalidSensorsData: + except InvalidSensorsDataError: errors[CONF_STATION_ID] = "invalid_sensors_data" return self.async_show_form( diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 6b3051a4bdd8..41954645f5c4 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==2.3.0"] + "requirements": ["gios==3.1.0"] } diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index cabbb671aedc..9c73b358897f 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -60,6 +60,10 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_AQI, name="AQI", value=None, + icon="mdi:air-filter", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="aqi", ), GiosSensorEntityDescription( key=ATTR_C6H6, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index 18db42b69c01..a76bd3f612cd 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -22,5 +22,19 @@ "info": { "can_reach_server": "Reach GIO\u015a server" } + }, + "entity": { + "sensor": { + "aqi": { + "state": { + "very_bad": "Very bad", + "bad": "Bad", + "sufficient": "Sufficient", + "moderate": "Moderate", + "good": "Good", + "very_good": "Very good" + } + } + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 1a948b2d7d93..1c7d0f4687db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -786,7 +786,7 @@ georss_qld_bushfire_alert_client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==2.3.0 +gios==3.1.0 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebcac904941f..b103783ce92d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -602,7 +602,7 @@ georss_qld_bushfire_alert_client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==2.3.0 +gios==3.1.0 # homeassistant.components.glances glances_api==0.4.1 diff --git a/tests/components/gios/fixtures/diagnostics_data.json b/tests/components/gios/fixtures/diagnostics_data.json index 1044c9887bbe..feee534ec314 100644 --- a/tests/components/gios/fixtures/diagnostics_data.json +++ b/tests/components/gios/fixtures/diagnostics_data.json @@ -3,48 +3,48 @@ "name": "AQI", "id": null, "index": null, - "value": "dobry" + "value": "good" }, "c6h6": { - "name": "benzen", + "name": "benzene", "id": 658, - "index": "bardzo dobry", + "index": "very_good", "value": 0.23789 }, "co": { - "name": "tlenek węgla", + "name": "carbon monoxide", "id": 660, - "index": "dobry", + "index": "good", "value": 251.874 }, "no2": { - "name": "dwutlenek azotu", + "name": "nitrogen dioxide", "id": 665, - "index": "dobry", + "index": "good", "value": 7.13411 }, "o3": { - "name": "ozon", + "name": "ozone", "id": 667, - "index": "dobry", + "index": "good", "value": 95.7768 }, "pm10": { - "name": "py\u0142 zawieszony PM10", + "name": "particulate matter 10", "id": 14395, - "index": "dobry", + "index": "good", "value": 16.8344 }, "pm25": { - "name": "py\u0142 zawieszony PM2.5", + "name": "particulate matter 2.5", "id": 670, - "index": "dobry", + "index": "good", "value": 4 }, "so2": { - "name": "dwutlenek siarki", + "name": "sulfur dioxide", "id": 672, - "index": "bardzo dobry", + "index": "very_good", "value": 4.35478 } } diff --git a/tests/components/gios/fixtures/indexes.json b/tests/components/gios/fixtures/indexes.json index cee504b0cc71..c53d1c78f6e1 100644 --- a/tests/components/gios/fixtures/indexes.json +++ b/tests/components/gios/fixtures/indexes.json @@ -1,28 +1,28 @@ { "id": 123, "stCalcDate": "2020-07-31 15:10:17", - "stIndexLevel": { "id": 1, "indexLevelName": "dobry" }, + "stIndexLevel": { "id": 1, "indexLevelName": "Dobry" }, "stSourceDataDate": "2020-07-31 14:00:00", "so2CalcDate": "2020-07-31 15:10:17", - "so2IndexLevel": { "id": 0, "indexLevelName": "bardzo dobry" }, + "so2IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, "so2SourceDataDate": "2020-07-31 14:00:00", "no2CalcDate": 1596201017000, - "no2IndexLevel": { "id": 0, "indexLevelName": "dobry" }, + "no2IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, "no2SourceDataDate": "2020-07-31 14:00:00", "coCalcDate": "2020-07-31 15:10:17", - "coIndexLevel": { "id": 0, "indexLevelName": "dobry" }, + "coIndexLevel": { "id": 0, "indexLevelName": "Dobry" }, "coSourceDataDate": "2020-07-31 14:00:00", "pm10CalcDate": "2020-07-31 15:10:17", - "pm10IndexLevel": { "id": 0, "indexLevelName": "dobry" }, + "pm10IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, "pm10SourceDataDate": "2020-07-31 14:00:00", "pm25CalcDate": "2020-07-31 15:10:17", - "pm25IndexLevel": { "id": 0, "indexLevelName": "dobry" }, + "pm25IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, "pm25SourceDataDate": "2020-07-31 14:00:00", "o3CalcDate": "2020-07-31 15:10:17", - "o3IndexLevel": { "id": 1, "indexLevelName": "dobry" }, + "o3IndexLevel": { "id": 1, "indexLevelName": "Dobry" }, "o3SourceDataDate": "2020-07-31 14:00:00", "c6h6CalcDate": "2020-07-31 15:10:17", - "c6h6IndexLevel": { "id": 0, "indexLevelName": "bardzo dobry" }, + "c6h6IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, "c6h6SourceDataDate": "2020-07-31 14:00:00", "stIndexStatus": true, "stIndexCrParam": "OZON" diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 9dbdda7d2eeb..c5b19502a0ff 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.gios.const import ( DOMAIN, ) from homeassistant.components.sensor import ( + ATTR_OPTIONS, ATTR_STATE_CLASS, DOMAIN as PLATFORM, SensorDeviceClass, @@ -50,7 +51,7 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" + assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_c6h6") assert entry @@ -67,7 +68,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_co") assert entry @@ -84,7 +85,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_no2") assert entry @@ -101,7 +102,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_o3") assert entry @@ -118,7 +119,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm10") assert entry @@ -135,7 +136,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "dobry" + assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm2_5") assert entry @@ -152,7 +153,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" + assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_so2") assert entry @@ -160,11 +161,19 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_aqi") assert state - assert state.state == "dobry" + assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] entry = registry.async_get("sensor.home_aqi") assert entry @@ -342,7 +351,7 @@ async def test_aqi_sensor_availability(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_aqi") assert state assert state.state != STATE_UNAVAILABLE - assert state.state == "dobry" + assert state.state == "good" future = utcnow() + timedelta(minutes=60) with patch( From 83fa4c6c600135de7106b8d247e6b78912c4aa8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 07:49:54 -1000 Subject: [PATCH 0273/1058] Bump aioesphomeapi to 13.4.2 (#89210) --- 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 fde8c26ba5ea..e8e4e4876f02 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.4.1", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.4.2", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 1c7d0f4687db..327edb7c99ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.1 +aioesphomeapi==13.4.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b103783ce92d..6f46d5e74a76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.1 +aioesphomeapi==13.4.2 # homeassistant.components.flo aioflo==2021.11.0 From 84034959baa69cdc03b74e0bda1186b5121bd580 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Mar 2023 21:54:34 +0100 Subject: [PATCH 0274/1058] Improve reolink generic typing (#88786) Co-authored-by: starkillerOG --- homeassistant/components/reolink/__init__.py | 9 ++--- .../components/reolink/binary_sensor.py | 4 +-- homeassistant/components/reolink/button.py | 4 +-- homeassistant/components/reolink/camera.py | 6 ++-- homeassistant/components/reolink/entity.py | 35 ++++++++++++------- homeassistant/components/reolink/light.py | 4 +-- homeassistant/components/reolink/number.py | 4 +-- homeassistant/components/reolink/select.py | 4 +-- homeassistant/components/reolink/siren.py | 4 +-- homeassistant/components/reolink/switch.py | 6 ++-- homeassistant/components/reolink/update.py | 9 +++-- 11 files changed, 49 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 94d4c1561ab8..c3d8df61f5af 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -6,6 +6,7 @@ import asyncio from dataclasses import dataclass from datetime import timedelta import logging +from typing import Literal from aiohttp import ClientConnectorError import async_timeout @@ -43,8 +44,8 @@ class ReolinkData: """Data for the Reolink integration.""" host: ReolinkHost - device_coordinator: DataUpdateCoordinator - firmware_coordinator: DataUpdateCoordinator + device_coordinator: DataUpdateCoordinator[None] + firmware_coordinator: DataUpdateCoordinator[str | Literal[False]] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -74,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) - async def async_device_config_update(): + async def async_device_config_update() -> None: """Update the host state cache and renew the ONVIF-subscription.""" async with async_timeout.timeout(host.api.timeout): try: @@ -87,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with async_timeout.timeout(host.api.timeout): await host.renew() - async def async_check_firmware_update(): + async def async_check_firmware_update() -> str | Literal[False]: """Check for firmware updates.""" if not host.api.supported(None, "update"): return False diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 3c97087c89d2..1a7649f367ac 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -113,7 +113,7 @@ async def async_setup_entry( async_add_entities(entities) -class ReolinkBinarySensorEntity(ReolinkCoordinatorEntity, BinarySensorEntity): +class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEntity): """Base binary-sensor class for Reolink IP camera motion sensors.""" entity_description: ReolinkBinarySensorEntityDescription diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 528eb8c74052..65bb8036c0bc 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -112,7 +112,7 @@ async def async_setup_entry( ) -class ReolinkButtonEntity(ReolinkCoordinatorEntity, ButtonEntity): +class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): """Base button entity class for Reolink IP cameras.""" entity_description: ReolinkButtonEntityDescription diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 4a270d6f5a69..13471df33925 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -39,7 +39,7 @@ async def async_setup_entry( async_add_entities(cameras) -class ReolinkCamera(ReolinkCoordinatorEntity, Camera): +class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): """An implementation of a Reolink IP camera.""" _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM @@ -51,7 +51,7 @@ class ReolinkCamera(ReolinkCoordinatorEntity, Camera): stream: str, ) -> None: """Initialize Reolink camera stream.""" - ReolinkCoordinatorEntity.__init__(self, reolink_data, channel) + ReolinkChannelCoordinatorEntity.__init__(self, reolink_data, channel) Camera.__init__(self) self._stream = stream diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 5f983ab34945..3a962d099dfd 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,6 +1,8 @@ """Reolink parent entity class.""" from __future__ import annotations +from typing import TypeVar + from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -11,24 +13,20 @@ from homeassistant.helpers.update_coordinator import ( from . import ReolinkData from .const import DOMAIN +_T = TypeVar("_T") -class ReolinkBaseCoordinatorEntity(CoordinatorEntity): - """Parent class for entities that control the Reolink NVR itself, without a channel. - A camera connected directly to HomeAssistant without using a NVR is in the reolink API - basically a NVR with a single channel that has the camera connected to that channel. - """ +class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): + """Parent class fo Reolink entities.""" _attr_has_entity_name = True def __init__( self, reolink_data: ReolinkData, - coordinator: DataUpdateCoordinator | None = None, + coordinator: DataUpdateCoordinator[_T], ) -> None: - """Initialize ReolinkBaseCoordinatorEntity for a NVR entity without a channel.""" - if coordinator is None: - coordinator = reolink_data.device_coordinator + """Initialize ReolinkBaseCoordinatorEntity.""" super().__init__(coordinator) self._host = reolink_data.host @@ -52,17 +50,28 @@ class ReolinkBaseCoordinatorEntity(CoordinatorEntity): return self._host.api.session_active and super().available -class ReolinkCoordinatorEntity(ReolinkBaseCoordinatorEntity): +class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): + """Parent class for entities that control the Reolink NVR itself, without a channel. + + A camera connected directly to HomeAssistant without using a NVR is in the reolink API + basically a NVR with a single channel that has the camera connected to that channel. + """ + + def __init__(self, reolink_data: ReolinkData) -> None: + """Initialize ReolinkHostCoordinatorEntity.""" + super().__init__(reolink_data, reolink_data.device_coordinator) + + +class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" def __init__( self, reolink_data: ReolinkData, channel: int, - coordinator: DataUpdateCoordinator | None = None, ) -> None: - """Initialize ReolinkCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" - super().__init__(reolink_data, coordinator) + """Initialize ReolinkChannelCoordinatorEntity for a hardware camera connected to a channel of the NVR.""" + super().__init__(reolink_data) self._channel = channel diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index dd71f91bb0ba..c4923c0088bf 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -89,7 +89,7 @@ async def async_setup_entry( ) -class ReolinkLightEntity(ReolinkCoordinatorEntity, LightEntity): +class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): """Base light entity class for Reolink IP cameras.""" entity_description: ReolinkLightEntityDescription diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 82c1924e27db..7c50bfa9f071 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -194,7 +194,7 @@ async def async_setup_entry( ) -class ReolinkNumberEntity(ReolinkCoordinatorEntity, NumberEntity): +class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): """Base number entity class for Reolink IP cameras.""" entity_description: ReolinkNumberEntityDescription diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 8df4afba735d..c7bd621a4bc3 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -86,7 +86,7 @@ async def async_setup_entry( ) -class ReolinkSelectEntity(ReolinkCoordinatorEntity, SelectEntity): +class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): """Base select entity class for Reolink IP cameras.""" entity_description: ReolinkSelectEntityDescription diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index f2b27dda4d18..405c3e2716de 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity @dataclass @@ -56,7 +56,7 @@ async def async_setup_entry( ) -class ReolinkSirenEntity(ReolinkCoordinatorEntity, SirenEntity): +class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): """Base siren entity class for Reolink IP cameras.""" _attr_supported_features = ( diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 64d615548566..a7ed9b6a98d9 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkBaseCoordinatorEntity, ReolinkCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity @dataclass @@ -172,7 +172,7 @@ async def async_setup_entry( async_add_entities(entities) -class ReolinkSwitchEntity(ReolinkCoordinatorEntity, SwitchEntity): +class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): """Base switch entity class for Reolink IP cameras.""" entity_description: ReolinkSwitchEntityDescription @@ -207,7 +207,7 @@ class ReolinkSwitchEntity(ReolinkCoordinatorEntity, SwitchEntity): self.async_write_ha_state() -class ReolinkNVRSwitchEntity(ReolinkBaseCoordinatorEntity, SwitchEntity): +class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): """Switch entity class for Reolink NVR features.""" entity_description: ReolinkNVRSwitchEntityDescription diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 5752afc92aca..aeb44cb77408 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, Literal from reolink_aio.exceptions import ReolinkError @@ -34,7 +34,9 @@ async def async_setup_entry( async_add_entities([ReolinkUpdateEntity(reolink_data)]) -class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): +class ReolinkUpdateEntity( + ReolinkBaseCoordinatorEntity[str | Literal[False]], UpdateEntity +): """Update entity for a Netgear device.""" _attr_device_class = UpdateDeviceClass.FIRMWARE @@ -59,9 +61,6 @@ class ReolinkUpdateEntity(ReolinkBaseCoordinatorEntity, UpdateEntity): @property def latest_version(self) -> str | None: """Latest version available for install.""" - if self.coordinator.data is None: - return None - if not self.coordinator.data: return self.installed_version From 7972dbf9fb1666e35b2ea92725170aeee07bc97e Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Mon, 6 Mar 2023 22:48:40 +0100 Subject: [PATCH 0275/1058] Bump python-snapcast to 2.3.2 (#89259) --- CODEOWNERS | 1 + homeassistant/components/snapcast/manifest.json | 4 ++-- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index fb122a5a0e34..46d78113abbc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1101,6 +1101,7 @@ build.json @home-assistant/supervisor /homeassistant/components/smhi/ @gjohansson-ST /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo +/homeassistant/components/snapcast/ @luar123 /homeassistant/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst /homeassistant/components/solaredge/ @frenck diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index d69f06f69831..bdcadc84e7c1 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -1,9 +1,9 @@ { "domain": "snapcast", "name": "Snapcast", - "codeowners": [], + "codeowners": ["@luar123"], "documentation": "https://www.home-assistant.io/integrations/snapcast", "iot_class": "local_polling", "loggers": ["construct", "snapcast"], - "requirements": ["snapcast==2.3.0"] + "requirements": ["snapcast==2.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 327edb7c99ab..355cd9c4b152 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2367,7 +2367,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.snapcast -snapcast==2.3.0 +snapcast==2.3.2 # homeassistant.components.sonos soco==0.29.1 From 5ccaa549d10d5529fe5af9d92f6102e2a1322e73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 14:04:10 -1000 Subject: [PATCH 0276/1058] Bump aioesphomeapi to 13.5.0 (#89262) --- 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 e8e4e4876f02..54cdc23355f3 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.4.2", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.5.0", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 355cd9c4b152..cc8fa98d2ffa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.2 +aioesphomeapi==13.5.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f46d5e74a76..1c5ed55bc64f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -143,7 +143,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.4.2 +aioesphomeapi==13.5.0 # homeassistant.components.flo aioflo==2021.11.0 From ee89922c1b8c86786e8d0fe0993b63975586b82c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 14:24:35 -1000 Subject: [PATCH 0277/1058] Add support for bluetooth pairing in esphome (#88603) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../components/esphome/bluetooth/client.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 545a436ee8b9..6ec51cabeb23 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -43,6 +43,7 @@ CCCD_NOTIFY_BYTES = b"\x01\x00" CCCD_INDICATE_BYTES = b"\x02\x00" MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE = 3 +MIN_BLUETOOTH_PROXY_HAS_PAIRING = 4 DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -386,13 +387,33 @@ class ESPHomeClient(BaseBleakClient): @api_error_as_bleak_error async def pair(self, *args: Any, **kwargs: Any) -> bool: """Attempt to pair.""" - raise NotImplementedError("Pairing is not available in ESPHome.") + if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + raise NotImplementedError( + "Pairing is not available in ESPHome with version {self._connection_version}." + ) + response = await self._client.bluetooth_device_pair(self._address_as_int) + if response.paired: + return True + _LOGGER.error( + "Pairing with %s failed due to error: %s", self.address, response.error + ) + return False @verify_connected @api_error_as_bleak_error async def unpair(self) -> bool: """Attempt to unpair.""" - raise NotImplementedError("Pairing is not available in ESPHome.") + if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_PAIRING: + raise NotImplementedError( + "Unpairing is not available in ESPHome with version {self._connection_version}." + ) + response = await self._client.bluetooth_device_unpair(self._address_as_int) + if response.success: + return True + _LOGGER.error( + "Unpairing with %s failed due to error: %s", self.address, response.error + ) + return False @api_error_as_bleak_error async def get_services( From 9672b5f02cd453f0ea4b1f6a6a32f47098ad87ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 15:20:37 -1000 Subject: [PATCH 0278/1058] Bump sqlalchemy to 2.0.5post1 (#89253) changelog: https://docs.sqlalchemy.org/en/20/changelog/changelog_20.html#change-2.0.5 mostly bugfixes for 2.x regressions --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index f40f866808c3..ed885127b1be 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.4", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.5.post1", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index e3efa81e44a0..bdedbb9b2077 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.4"] + "requirements": ["sqlalchemy==2.0.5.post1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5ce52033640d..915364219d64 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.4 +sqlalchemy==2.0.5.post1 typing-extensions>=4.5.0,<5.0 ulid-transform==0.4.0 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index cc8fa98d2ffa..5a9e407710b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2398,7 +2398,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.4 +sqlalchemy==2.0.5.post1 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c5ed55bc64f..1821c6b23945 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1704,7 +1704,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.4 +sqlalchemy==2.0.5.post1 # homeassistant.components.srp_energy srpenergy==1.3.6 From 3c70dd9b425fdab979795c68bd86f6bff4b4e8f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 15:44:11 -1000 Subject: [PATCH 0279/1058] Make sql subqueries threadsafe (#89254) * Make sql subqueries threadsafe fixes #89224 * fix join outside of lambda * move statement generation into a seperate function to make it easier to test * add cache key tests * no need to mock hass --- homeassistant/components/recorder/history.py | 162 +++++++---------- .../components/recorder/statistics.py | 171 +++++++++--------- tests/components/recorder/test_statistics.py | 103 ++++++++++- 3 files changed, 257 insertions(+), 179 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index fb1a55cebfb7..b67790f9a429 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -17,7 +17,6 @@ from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.sql.selectable import Subquery from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id @@ -592,48 +591,6 @@ def get_last_state_changes( ) -def _generate_most_recent_states_for_entities_by_date( - schema_version: int, - run_start: datetime, - utc_point_in_time: datetime, - entity_ids: list[str], -) -> Subquery: - """Generate the sub query for the most recent states for specific entities by date.""" - if schema_version >= 31: - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - return ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) - ) - .filter(States.entity_id.in_(entity_ids)) - .group_by(States.entity_id) - .subquery() - ) - return ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated).label("max_last_updated"), - ) - .filter( - (States.last_updated >= run_start) - & (States.last_updated < utc_point_in_time) - ) - .filter(States.entity_id.in_(entity_ids)) - .group_by(States.entity_id) - .subquery() - ) - - def _get_states_for_entities_stmt( schema_version: int, run_start: datetime, @@ -645,16 +602,29 @@ def _get_states_for_entities_stmt( stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) - most_recent_states_for_entities_by_date = ( - _generate_most_recent_states_for_entities_by_date( - schema_version, run_start, utc_point_in_time, entity_ids - ) - ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. if schema_version >= 31: + run_start_ts = process_timestamp(run_start).timestamp() + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) stmt += lambda q: q.join( - most_recent_states_for_entities_by_date, + ( + most_recent_states_for_entities_by_date := ( + select( + States.entity_id.label("max_entity_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < utc_point_in_time_ts) + ) + .filter(States.entity_id.in_(entity_ids)) + .group_by(States.entity_id) + .subquery() + ) + ), and_( States.entity_id == most_recent_states_for_entities_by_date.c.max_entity_id, @@ -664,7 +634,21 @@ def _get_states_for_entities_stmt( ) else: stmt += lambda q: q.join( - most_recent_states_for_entities_by_date, + ( + most_recent_states_for_entities_by_date := select( + States.entity_id.label("max_entity_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated).label("max_last_updated"), + ) + .filter( + (States.last_updated >= run_start) + & (States.last_updated < utc_point_in_time) + ) + .filter(States.entity_id.in_(entity_ids)) + .group_by(States.entity_id) + .subquery() + ), and_( States.entity_id == most_recent_states_for_entities_by_date.c.max_entity_id, @@ -679,45 +663,6 @@ def _get_states_for_entities_stmt( return stmt -def _generate_most_recent_states_by_date( - schema_version: int, - run_start: datetime, - utc_point_in_time: datetime, -) -> Subquery: - """Generate the sub query for the most recent states by date.""" - if schema_version >= 31: - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - return ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) - ) - .group_by(States.entity_id) - .subquery() - ) - return ( - select( - States.entity_id.label("max_entity_id"), - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(States.last_updated).label("max_last_updated"), - ) - .filter( - (States.last_updated >= run_start) - & (States.last_updated < utc_point_in_time) - ) - .group_by(States.entity_id) - .subquery() - ) - - def _get_states_for_all_stmt( schema_version: int, run_start: datetime, @@ -733,12 +678,26 @@ def _get_states_for_all_stmt( # query, then filter out unwanted domains as well as applying the custom filter. # This filtering can't be done in the inner query because the domain column is # not indexed and we can't control what's in the custom filter. - most_recent_states_by_date = _generate_most_recent_states_by_date( - schema_version, run_start, utc_point_in_time - ) if schema_version >= 31: + run_start_ts = process_timestamp(run_start).timestamp() + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) stmt += lambda q: q.join( - most_recent_states_by_date, + ( + most_recent_states_by_date := ( + select( + States.entity_id.label("max_entity_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < utc_point_in_time_ts) + ) + .group_by(States.entity_id) + .subquery() + ) + ), and_( States.entity_id == most_recent_states_by_date.c.max_entity_id, States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, @@ -746,7 +705,22 @@ def _get_states_for_all_stmt( ) else: stmt += lambda q: q.join( - most_recent_states_by_date, + ( + most_recent_states_by_date := ( + select( + States.entity_id.label("max_entity_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated).label("max_last_updated"), + ) + .filter( + (States.last_updated >= run_start) + & (States.last_updated < utc_point_in_time) + ) + .group_by(States.entity_id) + .subquery() + ) + ), and_( States.entity_id == most_recent_states_by_date.c.max_entity_id, States.last_updated == most_recent_states_by_date.c.max_last_updated, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index fee7be443b7a..4cc6e40fa695 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -16,14 +16,13 @@ import re from statistics import mean from typing import TYPE_CHECKING, Any, Literal, cast -from sqlalchemy import and_, bindparam, func, lambda_stmt, select, text +from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text from sqlalchemy.engine import Engine from sqlalchemy.engine.row import Row from sqlalchemy.exc import OperationalError, SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal_column, true from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.sql.selectable import Subquery import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT @@ -650,27 +649,19 @@ def _compile_hourly_statistics_summary_mean_stmt( ) -def _compile_hourly_statistics_last_sum_stmt_subquery( - start_time_ts: float, end_time_ts: float -) -> Subquery: - """Generate the summary mean statement for hourly statistics.""" - return ( - select(*QUERY_STATISTICS_SUMMARY_SUM) - .filter(StatisticsShortTerm.start_ts >= start_time_ts) - .filter(StatisticsShortTerm.start_ts < end_time_ts) - .subquery() - ) - - def _compile_hourly_statistics_last_sum_stmt( start_time_ts: float, end_time_ts: float ) -> StatementLambdaElement: """Generate the summary mean statement for hourly statistics.""" - subquery = _compile_hourly_statistics_last_sum_stmt_subquery( - start_time_ts, end_time_ts - ) return lambda_stmt( - lambda: select(subquery) + lambda: select( + subquery := ( + select(*QUERY_STATISTICS_SUMMARY_SUM) + .filter(StatisticsShortTerm.start_ts >= start_time_ts) + .filter(StatisticsShortTerm.start_ts < end_time_ts) + .subquery() + ) + ) .filter(subquery.c.rownum == 1) .order_by(subquery.c.metadata_id) ) @@ -1267,7 +1258,8 @@ def _reduce_statistics_per_month( ) -def _statistics_during_period_stmt( +def _generate_statistics_during_period_stmt( + columns: Select, start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, @@ -1279,21 +1271,6 @@ def _statistics_during_period_stmt( This prepares a lambda_stmt query, so we don't insert the parameters yet. """ start_time_ts = start_time.timestamp() - - columns = select(table.metadata_id, table.start_ts) - if "last_reset" in types: - columns = columns.add_columns(table.last_reset_ts) - if "max" in types: - columns = columns.add_columns(table.max) - if "mean" in types: - columns = columns.add_columns(table.mean) - if "min" in types: - columns = columns.add_columns(table.min) - if "state" in types: - columns = columns.add_columns(table.state) - if "sum" in types: - columns = columns.add_columns(table.sum) - stmt = lambda_stmt(lambda: columns.filter(table.start_ts >= start_time_ts)) if end_time is not None: end_time_ts = end_time.timestamp() @@ -1307,6 +1284,23 @@ def _statistics_during_period_stmt( return stmt +def _generate_max_mean_min_statistic_in_sub_period_stmt( + columns: Select, + start_time: datetime | None, + end_time: datetime | None, + table: type[StatisticsBase], + metadata_id: int, +) -> StatementLambdaElement: + stmt = lambda_stmt(lambda: columns.filter(table.metadata_id == metadata_id)) + if start_time is not None: + start_time_ts = start_time.timestamp() + stmt += lambda q: q.filter(table.start_ts >= start_time_ts) + if end_time is not None: + end_time_ts = end_time.timestamp() + stmt += lambda q: q.filter(table.start_ts < end_time_ts) + return stmt + + def _get_max_mean_min_statistic_in_sub_period( session: Session, result: dict[str, float], @@ -1332,13 +1326,9 @@ def _get_max_mean_min_statistic_in_sub_period( # https://github.com/sqlalchemy/sqlalchemy/issues/9189 # pylint: disable-next=not-callable columns = columns.add_columns(func.min(table.min)) - stmt = lambda_stmt(lambda: columns.filter(table.metadata_id == metadata_id)) - if start_time is not None: - start_time_ts = start_time.timestamp() - stmt += lambda q: q.filter(table.start_ts >= start_time_ts) - if end_time is not None: - end_time_ts = end_time.timestamp() - stmt += lambda q: q.filter(table.start_ts < end_time_ts) + stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( + columns, start_time, end_time, table, metadata_id + ) stats = cast(Sequence[Row[Any]], execute_stmt_lambda_element(session, stmt)) if not stats: return @@ -1753,8 +1743,21 @@ def _statistics_during_period_with_session( table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm ) - stmt = _statistics_during_period_stmt( - start_time, end_time, metadata_ids, table, types + columns = select(table.metadata_id, table.start_ts) # type: ignore[call-overload] + if "last_reset" in types: + columns = columns.add_columns(table.last_reset_ts) + if "max" in types: + columns = columns.add_columns(table.max) + if "mean" in types: + columns = columns.add_columns(table.mean) + if "min" in types: + columns = columns.add_columns(table.min) + if "state" in types: + columns = columns.add_columns(table.state) + if "sum" in types: + columns = columns.add_columns(table.sum) + stmt = _generate_statistics_during_period_stmt( + columns, start_time, end_time, metadata_ids, table, types ) stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) @@ -1919,28 +1922,24 @@ def get_last_short_term_statistics( ) -def _generate_most_recent_statistic_row(metadata_ids: list[int]) -> Subquery: - """Generate the subquery to find the most recent statistic row.""" - return ( - select( - StatisticsShortTerm.metadata_id, - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(StatisticsShortTerm.start_ts).label("start_max"), - ) - .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - .group_by(StatisticsShortTerm.metadata_id) - ).subquery() - - def _latest_short_term_statistics_stmt( metadata_ids: list[int], ) -> StatementLambdaElement: """Create the statement for finding the latest short term stat rows.""" stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) - most_recent_statistic_row = _generate_most_recent_statistic_row(metadata_ids) stmt += lambda s: s.join( - most_recent_statistic_row, + ( + most_recent_statistic_row := ( + select( + StatisticsShortTerm.metadata_id, + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(StatisticsShortTerm.start_ts).label("start_max"), + ) + .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) + .group_by(StatisticsShortTerm.metadata_id) + ).subquery() + ), ( StatisticsShortTerm.metadata_id # pylint: disable=comparison-with-callable == most_recent_statistic_row.c.metadata_id @@ -1988,21 +1987,34 @@ def get_latest_short_term_statistics( ) -def _get_most_recent_statistics_subquery( - metadata_ids: set[int], table: type[StatisticsBase], start_time_ts: float -) -> Subquery: - """Generate the subquery to find the most recent statistic row.""" - return ( - select( - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(table.start_ts).label("max_start_ts"), - table.metadata_id.label("max_metadata_id"), +def _generate_statistics_at_time_stmt( + columns: Select, + table: type[StatisticsBase], + metadata_ids: set[int], + start_time_ts: float, +) -> StatementLambdaElement: + """Create the statement for finding the statistics for a given time.""" + return lambda_stmt( + lambda: columns.join( + ( + most_recent_statistic_ids := ( + select( + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(table.start_ts).label("max_start_ts"), + table.metadata_id.label("max_metadata_id"), + ) + .filter(table.start_ts < start_time_ts) + .filter(table.metadata_id.in_(metadata_ids)) + .group_by(table.metadata_id) + .subquery() + ) + ), + and_( + table.start_ts == most_recent_statistic_ids.c.max_start_ts, + table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, + ), ) - .filter(table.start_ts < start_time_ts) - .filter(table.metadata_id.in_(metadata_ids)) - .group_by(table.metadata_id) - .subquery() ) @@ -2027,19 +2039,10 @@ def _statistics_at_time( columns = columns.add_columns(table.state) if "sum" in types: columns = columns.add_columns(table.sum) - start_time_ts = start_time.timestamp() - most_recent_statistic_ids = _get_most_recent_statistics_subquery( - metadata_ids, table, start_time_ts + stmt = _generate_statistics_at_time_stmt( + columns, table, metadata_ids, start_time_ts ) - stmt = lambda_stmt(lambda: columns).join( - most_recent_statistic_ids, - and_( - table.start_ts == most_recent_statistic_ids.c.max_start_ts, - table.metadata_id == most_recent_statistic_ids.c.max_metadata_id, - ), - ) - return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index dd51946c86fe..e6ae291264fa 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -8,7 +8,7 @@ import sys from unittest.mock import ANY, DEFAULT, MagicMock, patch, sentinel import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, select from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session @@ -22,6 +22,10 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, + _generate_get_metadata_stmt, + _generate_max_mean_min_statistic_in_sub_period_stmt, + _generate_statistics_at_time_stmt, + _generate_statistics_during_period_stmt, _statistics_during_period_with_session, _update_or_add_metadata, async_add_external_statistics, @@ -1799,3 +1803,100 @@ def record_states(hass): states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) return zero, four, states + + +def test_cache_key_for_generate_statistics_during_period_stmt(): + """Test cache key for _generate_statistics_during_period_stmt.""" + columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) + stmt = _generate_statistics_during_period_stmt( + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_during_period_stmt( + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + columns2 = select( + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start_ts, + StatisticsShortTerm.sum, + StatisticsShortTerm.mean, + ) + stmt3 = _generate_statistics_during_period_stmt( + columns2, + dt_util.utcnow(), + dt_util.utcnow(), + [0], + StatisticsShortTerm, + {"max", "mean"}, + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +def test_cache_key_for_generate_get_metadata_stmt(): + """Test cache key for _generate_get_metadata_stmt.""" + stmt_mean = _generate_get_metadata_stmt([0], "mean") + stmt_mean2 = _generate_get_metadata_stmt([1], "mean") + stmt_sum = _generate_get_metadata_stmt([0], "sum") + stmt_none = _generate_get_metadata_stmt() + assert stmt_mean._generate_cache_key() == stmt_mean2._generate_cache_key() + assert stmt_mean._generate_cache_key() != stmt_sum._generate_cache_key() + assert stmt_mean._generate_cache_key() != stmt_none._generate_cache_key() + + +def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt(): + """Test cache key for _generate_max_mean_min_statistic_in_sub_period_stmt.""" + columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) + stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( + columns, + dt_util.utcnow(), + dt_util.utcnow(), + StatisticsShortTerm, + [0], + ) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_max_mean_min_statistic_in_sub_period_stmt( + columns, + dt_util.utcnow(), + dt_util.utcnow(), + StatisticsShortTerm, + [0], + ) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + columns2 = select( + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start_ts, + StatisticsShortTerm.sum, + StatisticsShortTerm.mean, + ) + stmt3 = _generate_max_mean_min_statistic_in_sub_period_stmt( + columns2, + dt_util.utcnow(), + dt_util.utcnow(), + StatisticsShortTerm, + [0], + ) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 + + +def test_cache_key_for_generate_statistics_at_time_stmt(): + """Test cache key for _generate_statistics_at_time_stmt.""" + columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) + stmt = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + cache_key_1 = stmt._generate_cache_key() + stmt2 = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) + cache_key_2 = stmt2._generate_cache_key() + assert cache_key_1 == cache_key_2 + columns2 = select( + StatisticsShortTerm.metadata_id, + StatisticsShortTerm.start_ts, + StatisticsShortTerm.sum, + StatisticsShortTerm.mean, + ) + stmt3 = _generate_statistics_at_time_stmt(columns2, StatisticsShortTerm, {0}, 0.0) + cache_key_3 = stmt3._generate_cache_key() + assert cache_key_1 != cache_key_3 From 755c44d1525ab8fe185e5f068513f3f8205d9ef8 Mon Sep 17 00:00:00 2001 From: Doney den Ouden Date: Tue, 7 Mar 2023 05:07:43 +0100 Subject: [PATCH 0280/1058] Add HomeKit Door accessory type (#80741) Co-authored-by: Jason Redd Co-authored-by: J. Nick Koston --- .../components/homekit/accessories.py | 5 ++ homeassistant/components/homekit/const.py | 1 + .../components/homekit/type_covers.py | 14 +++++ .../homekit/test_get_accessories.py | 9 ++++ tests/components/homekit/test_type_covers.py | 53 +++++++++++++++++++ 5 files changed, 82 insertions(+) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index adab539fb307..dc8a2a7c639b 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -148,6 +148,11 @@ def get_accessory( # noqa: C901 and features & CoverEntityFeature.SET_POSITION ): a_type = "Window" + elif ( + device_class == CoverDeviceClass.DOOR + and features & CoverEntityFeature.SET_POSITION + ): + a_type = "Door" elif features & CoverEntityFeature.SET_POSITION: a_type = "WindowCovering" elif features & (CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE): diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 58e1e13a3f3b..4517f9c5a5e2 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -119,6 +119,7 @@ SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" SERV_CARBON_DIOXIDE_SENSOR = "CarbonDioxideSensor" SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" SERV_CONTACT_SENSOR = "ContactSensor" +SERV_DOOR = "Door" SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 4b21bfb77df0..05feb580572c 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -2,6 +2,7 @@ import logging from pyhap.const import ( + CATEGORY_DOOR, CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW, CATEGORY_WINDOW_COVERING, @@ -54,6 +55,7 @@ from .const import ( HK_POSITION_STOPPED, PROP_MAX_VALUE, PROP_MIN_VALUE, + SERV_DOOR, SERV_GARAGE_DOOR_OPENER, SERV_WINDOW, SERV_WINDOW_COVERING, @@ -323,6 +325,18 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): super().async_update_state(new_state) +@TYPES.register("Door") +class Door(OpeningDevice): + """Generate a Door accessory for a cover entity. + + The entity must support: set_cover_position. + """ + + def __init__(self, *args): + """Initialize a Door accessory object.""" + super().__init__(*args, category=CATEGORY_DOOR, service=SERV_DOOR) + + @TYPES.register("Window") class Window(OpeningDevice): """Generate a Window accessory for a cover entity with WINDOW device class. diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index b5d65993a878..08a7f8a2206a 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -160,6 +160,15 @@ def test_types(type_name, entity_id, state, attrs, config) -> None: ) }, ), + ( + "Door", + "cover.door", + "open", + { + ATTR_DEVICE_CLASS: "door", + ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + }, + ), ], ) def test_type_covers(type_name, entity_id, state, attrs) -> None: diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index e1c547315c1f..9da576b6a0e0 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -19,6 +19,7 @@ from homeassistant.components.homekit.const import ( PROP_MIN_VALUE, ) from homeassistant.components.homekit.type_covers import ( + Door, GarageDoorOpener, Window, WindowCovering, @@ -128,6 +129,58 @@ async def test_garage_door_open_close(hass: HomeAssistant, hk_driver, events) -> assert events[-1].data[ATTR_VALUE] is None +async def test_door_instantiate_set_position( + hass: HomeAssistant, hk_driver, events +) -> None: + """Test if Door accessory is instantiated correctly and can set position.""" + entity_id = "cover.door" + + hass.states.async_set( + entity_id, + STATE_OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + ATTR_CURRENT_POSITION: 0, + }, + ) + await hass.async_block_till_done() + acc = Door(hass, hk_driver, "Door", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 12 # Door + + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + + hass.states.async_set( + entity_id, + STATE_OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + ATTR_CURRENT_POSITION: 50, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 50 + assert acc.char_position_state.value == 2 + + hass.states.async_set( + entity_id, + STATE_OPEN, + { + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + ATTR_CURRENT_POSITION: "GARBAGE", + }, + ) + await hass.async_block_till_done() + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 50 + assert acc.char_position_state.value == 2 + + async def test_windowcovering_set_cover_position( hass: HomeAssistant, hk_driver, events ) -> None: From 85bcf11aeb8e7af8045ca2081411b548c8e8a7a1 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 7 Mar 2023 09:22:31 +0000 Subject: [PATCH 0281/1058] Update systembridgeconnector to 3.4.8 (#79732) Co-authored-by: Franck Nijhof --- .../components/system_bridge/manifest.json | 2 +- .../components/system_bridge/sensor.py | 69 ++++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 39 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 6146a32fd8ec..7462966ae394 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.4.4"], + "requirements": ["systembridgeconnector==3.4.8"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index bc02c9f1cdaf..eb835b2c9537 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -51,8 +51,8 @@ class SystemBridgeSensorEntityDescription(SensorEntityDescription): def battery_time_remaining(data: SystemBridgeCoordinatorData) -> datetime | None: """Return the battery time remaining.""" - if data.battery.sensors_secsleft is not None: - return utcnow() + timedelta(seconds=data.battery.sensors_secsleft) + if (value := getattr(data.battery, "sensors_secsleft", None)) is not None: + return utcnow() + timedelta(seconds=value) return None @@ -65,29 +65,29 @@ def cpu_speed(data: SystemBridgeCoordinatorData) -> float | None: def gpu_core_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the GPU core clock speed.""" - if getattr(data.gpu, f"{key}_core_clock") is not None: - return round(getattr(data.gpu, f"{key}_core_clock")) + if (value := getattr(data.gpu, f"{key}_core_clock", None)) is not None: + return round(value) return None def gpu_memory_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the GPU memory clock speed.""" - if getattr(data.gpu, f"{key}_memory_clock") is not None: - return round(getattr(data.gpu, f"{key}_memory_clock")) + if (value := getattr(data.gpu, f"{key}_memory_clock", None)) is not None: + return round(value) return None def gpu_memory_free(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the free GPU memory.""" - if getattr(data.gpu, f"{key}_memory_free") is not None: - return round(getattr(data.gpu, f"{key}_memory_free") / 10**3, 2) + if (value := getattr(data.gpu, f"{key}_memory_free", None)) is not None: + return round(value) return None def gpu_memory_used(data: SystemBridgeCoordinatorData, key: str) -> float | None: """Return the used GPU memory.""" - if getattr(data.gpu, f"{key}_memory_used") is not None: - return round(getattr(data.gpu, f"{key}_memory_used") / 10**3, 2) + if (value := getattr(data.gpu, f"{key}_memory_used", None)) is not None: + return round(value) return None @@ -95,14 +95,11 @@ def gpu_memory_used_percentage( data: SystemBridgeCoordinatorData, key: str ) -> float | None: """Return the used GPU memory percentage.""" - if ( - getattr(data.gpu, f"{key}_memory_used") is not None - and getattr(data.gpu, f"{key}_memory_total") is not None + if ((used := getattr(data.gpu, f"{key}_memory_used", None)) is not None) and ( + (total := getattr(data.gpu, f"{key}_memory_total", None)) is not None ): return round( - getattr(data.gpu, f"{key}_memory_used") - / getattr(data.gpu, f"{key}_memory_total") - * 100, + used / total * 100, 2, ) return None @@ -266,7 +263,7 @@ async def async_setup_entry( native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", value=lambda data, p=partition: getattr( - data.disk, f"usage_{p}_percent" + data.disk, f"usage_{p}_percent", None ), ), entry.data[CONF_PORT], @@ -283,15 +280,17 @@ async def async_setup_entry( SystemBridgeSensor(coordinator, description, entry.data[CONF_PORT]) ) - displays = [] - for display in coordinator.data.display.displays: - displays.append( + displays: list[dict[str, str]] = [] + if coordinator.data.display.displays is not None: + displays.extend( { "key": display, "name": getattr(coordinator.data.display, f"{display}_name").replace( "Display ", "" ), - }, + } + for display in coordinator.data.display.displays + if hasattr(coordinator.data.display, f"{display}_name") ) display_count = len(displays) @@ -321,7 +320,7 @@ async def async_setup_entry( native_unit_of_measurement=PIXELS, icon="mdi:monitor", value=lambda data, k=display["key"]: getattr( - data.display, f"{k}_resolution_horizontal" + data.display, f"{k}_resolution_horizontal", None ), ), entry.data[CONF_PORT], @@ -335,7 +334,7 @@ async def async_setup_entry( native_unit_of_measurement=PIXELS, icon="mdi:monitor", value=lambda data, k=display["key"]: getattr( - data.display, f"{k}_resolution_vertical" + data.display, f"{k}_resolution_vertical", None ), ), entry.data[CONF_PORT], @@ -350,20 +349,22 @@ async def async_setup_entry( device_class=SensorDeviceClass.FREQUENCY, icon="mdi:monitor", value=lambda data, k=display["key"]: getattr( - data.display, f"{k}_refresh_rate" + data.display, f"{k}_refresh_rate", None ), ), entry.data[CONF_PORT], ), ] - gpus = [] - for gpu in coordinator.data.gpu.gpus: - gpus.append( + gpus: list[dict[str, str]] = [] + if coordinator.data.gpu.gpus is not None: + gpus.extend( { "key": gpu, "name": getattr(coordinator.data.gpu, f"{gpu}_name"), - }, + } + for gpu in coordinator.data.gpu.gpus + if hasattr(coordinator.data.gpu, f"{gpu}_name") ) for index, gpu in enumerate(gpus): @@ -448,7 +449,7 @@ async def async_setup_entry( native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fan", value=lambda data, k=gpu["key"]: getattr( - data.gpu, f"{k}_fan_speed" + data.gpu, f"{k}_fan_speed", None ), ), entry.data[CONF_PORT], @@ -462,7 +463,9 @@ async def async_setup_entry( device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, - value=lambda data, k=gpu["key"]: getattr(data.gpu, f"{k}_power"), + value=lambda data, k=gpu["key"]: getattr( + data.gpu, f"{k}_power", None + ), ), entry.data[CONF_PORT], ), @@ -476,7 +479,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value=lambda data, k=gpu["key"]: getattr( - data.gpu, f"{k}_temperature" + data.gpu, f"{k}_temperature", None ), ), entry.data[CONF_PORT], @@ -490,7 +493,7 @@ async def async_setup_entry( native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", value=lambda data, k=gpu["key"]: getattr( - data.gpu, f"{k}_core_load" + data.gpu, f"{k}_core_load", None ), ), entry.data[CONF_PORT], @@ -509,7 +512,7 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda data, k=index: getattr(data.cpu, f"usage_{k}"), + value=lambda data, k=index: getattr(data.cpu, f"usage_{k}", None), ), entry.data[CONF_PORT], ), diff --git a/requirements_all.txt b/requirements_all.txt index 5a9e407710b5..0ca58159d054 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2449,7 +2449,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.4 +systembridgeconnector==3.4.8 # homeassistant.components.tailscale tailscale==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1821c6b23945..7d86bf8a0a2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1743,7 +1743,7 @@ sunwatcher==0.2.1 surepy==0.8.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.4 +systembridgeconnector==3.4.8 # homeassistant.components.tailscale tailscale==0.2.0 From c51bde9a26ec3a596cdd926a2c9d5ed148944dfa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Mar 2023 11:35:48 +0100 Subject: [PATCH 0282/1058] Fail CI on lingering tasks (#88905) --- tests/components/github/test_diagnostics.py | 5 +++++ tests/components/github/test_init.py | 6 ++++++ tests/components/github/test_sensor.py | 4 ++++ tests/components/insteon/test_api_aldb.py | 20 +++++++++++++++++++ tests/components/insteon/test_api_device.py | 3 +++ tests/components/insteon/test_api_scenes.py | 12 +++++++++++ tests/components/knx/test_climate.py | 12 +++++++++++ tests/components/knx/test_cover.py | 4 ++++ tests/components/lcn/test_events.py | 3 +++ tests/components/matter/test_adapter.py | 6 ++++++ tests/components/matter/test_api.py | 9 +++++++++ tests/components/matter/test_binary_sensor.py | 4 ++++ tests/components/matter/test_diagnostics.py | 4 ++++ tests/components/matter/test_helpers.py | 4 ++++ tests/components/matter/test_init.py | 8 ++++++++ tests/components/matter/test_light.py | 8 ++++++++ tests/components/matter/test_sensor.py | 12 +++++++++++ tests/components/matter/test_switch.py | 4 ++++ tests/components/nest/test_api.py | 4 ++++ tests/components/opentherm_gw/test_init.py | 5 +++++ tests/components/plex/test_config_flow.py | 6 ++++++ tests/components/snooz/test_fan.py | 4 ++++ tests/components/snooz/test_init.py | 6 ++++++ tests/conftest.py | 19 ++++++++++++++++-- 24 files changed, 170 insertions(+), 2 deletions(-) diff --git a/tests/components/github/test_diagnostics.py b/tests/components/github/test_diagnostics.py index f1c40a1fd196..4bd7563e743d 100644 --- a/tests/components/github/test_diagnostics.py +++ b/tests/components/github/test_diagnostics.py @@ -3,6 +3,7 @@ import json from aiogithubapi import GitHubException +import pytest from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN from homeassistant.core import HomeAssistant @@ -15,6 +16,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -54,6 +57,8 @@ async def test_entry_diagnostics( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_entry_diagnostics_exception( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/github/test_init.py b/tests/components/github/test_init.py index 6cb9539bbd84..f4557632d60b 100644 --- a/tests/components/github/test_init.py +++ b/tests/components/github/test_init.py @@ -11,6 +11,8 @@ from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_cleanup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -46,6 +48,8 @@ async def test_device_registry_cleanup( assert len(devices) == 0 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_subscription_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -61,6 +65,8 @@ async def test_subscription_setup( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_subscription_setup_polling_disabled( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/github/test_sensor.py b/tests/components/github/test_sensor.py index 892fb8956e6e..60574c5b60c9 100644 --- a/tests/components/github/test_sensor.py +++ b/tests/components/github/test_sensor.py @@ -1,6 +1,8 @@ """Test GitHub sensor.""" import json +import pytest + from homeassistant.components.github.const import DOMAIN, FALLBACK_UPDATE_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util import dt @@ -13,6 +15,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker TEST_SENSOR_ENTITY = "sensor.octocat_hello_world_latest_release" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_updates_with_empty_release_array( hass: HomeAssistant, init_integration: MockConfigEntry, diff --git a/tests/components/insteon/test_api_aldb.py b/tests/components/insteon/test_api_aldb.py index 3d8c2ef809d5..4bd299d3d05b 100644 --- a/tests/components/insteon/test_api_aldb.py +++ b/tests/components/insteon/test_api_aldb.py @@ -69,6 +69,8 @@ def _aldb_dict(mem_addr): } +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_aldb( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -85,6 +87,8 @@ async def test_get_aldb( assert len(result) == 5 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_change_aldb_record( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -108,6 +112,8 @@ async def test_change_aldb_record( _compare_records(rec, change_rec) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_create_aldb_record( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -131,6 +137,8 @@ async def test_create_aldb_record( _compare_records(rec, new_rec) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_write_aldb( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -152,6 +160,8 @@ async def test_write_aldb( assert devices.async_save.call_count == 1 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_load_aldb( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -172,6 +182,8 @@ async def test_load_aldb( assert devices.async_save.call_count == 1 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_reset_aldb( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -203,6 +215,8 @@ async def test_reset_aldb( assert not devices["33.33.33"].aldb.pending_changes +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_default_links( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -224,6 +238,8 @@ async def test_default_links( assert devices.async_save.call_count == 1 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_notify_on_aldb_status( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -247,6 +263,8 @@ async def test_notify_on_aldb_status( assert not msg["event"]["is_loading"] +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_notify_on_aldb_record_added( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: @@ -274,6 +292,8 @@ async def test_notify_on_aldb_record_added( assert msg["event"]["type"] == "record_loaded" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_bad_address( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data ) -> None: diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py index 9a4e6eba37c1..ce061e47c3dc 100644 --- a/tests/components/insteon/test_api_device.py +++ b/tests/components/insteon/test_api_device.py @@ -5,6 +5,7 @@ from unittest.mock import patch from pyinsteon.constants import DeviceAction from pyinsteon.topics import DEVICE_LIST_CHANGED from pyinsteon.utils import publish_topic +import pytest from homeassistant.components import insteon from homeassistant.components.insteon.api import async_load_api @@ -154,6 +155,8 @@ async def test_get_ha_device_name( assert name == "" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_add_device_api( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/insteon/test_api_scenes.py b/tests/components/insteon/test_api_scenes.py index 9730f2427f35..cc9b11f4632d 100644 --- a/tests/components/insteon/test_api_scenes.py +++ b/tests/components/insteon/test_api_scenes.py @@ -59,6 +59,8 @@ async def _setup(hass, hass_ws_client, scene_data): return ws_client, devices +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_scenes( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data ) -> None: @@ -73,6 +75,8 @@ async def test_get_scenes( assert len(result["20"]) == 3 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_scene( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data ) -> None: @@ -86,6 +90,8 @@ async def test_get_scene( assert len(result["devices"]) == 3 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_save_scene( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json ) -> None: @@ -115,6 +121,8 @@ async def test_save_scene( assert result["scene_id"] == 20 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_save_new_scene( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json ) -> None: @@ -144,6 +152,8 @@ async def test_save_new_scene( assert result["scene_id"] == 21 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_save_scene_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json ) -> None: @@ -173,6 +183,8 @@ async def test_save_scene_error( assert result["scene_id"] == 20 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_delete_scene( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, scene_data, remove_json ) -> None: diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index ac6f5e7e9727..1784d912be0c 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -1,4 +1,6 @@ """Test KNX climate.""" +import pytest + from homeassistant.components.climate import PRESET_ECO, PRESET_SLEEP, HVACMode from homeassistant.components.knx.schema import ClimateSchema from homeassistant.const import CONF_NAME, STATE_IDLE @@ -15,6 +17,8 @@ RAW_FLOAT_21_0 = (0x0C, 0x1A) RAW_FLOAT_22_0 = (0x0C, 0x4C) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_basic_temperature_set( hass: HomeAssistant, knx: KNXTestKit ) -> None: @@ -54,6 +58,8 @@ async def test_climate_basic_temperature_set( assert len(events) == 1 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate hvac mode.""" events = async_capture_events(hass, "state_changed") @@ -107,6 +113,8 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write("1/2/6", (0x01,)) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_preset_mode( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry ) -> None: @@ -174,6 +182,8 @@ async def test_climate_preset_mode( assert len(knx.xknx.devices) == 0 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test update climate entity for KNX.""" events = async_capture_events(hass, "state_changed") @@ -219,6 +229,8 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/7") +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate command_value.""" await knx.setup_integration( diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index ed82968b559d..066429a884ba 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -1,4 +1,6 @@ """Test KNX cover.""" +import pytest + from homeassistant.components.knx.schema import CoverSchema from homeassistant.const import CONF_NAME, STATE_CLOSING from homeassistant.core import HomeAssistant @@ -8,6 +10,8 @@ from .conftest import KNXTestKit from tests.common import async_capture_events +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX cover basic.""" events = async_capture_events(hass, "state_changed") diff --git a/tests/components/lcn/test_events.py b/tests/components/lcn/test_events.py index 0b0eefffacff..4e20e202ffc7 100644 --- a/tests/components/lcn/test_events.py +++ b/tests/components/lcn/test_events.py @@ -2,6 +2,7 @@ from pypck.inputs import Input, ModSendKeysHost, ModStatusAccessControl from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import AccessControlPeriphery, KeyAction, SendKeyCommand +import pytest from homeassistant.core import HomeAssistant @@ -137,6 +138,8 @@ async def test_dont_fire_on_non_module_input( assert len(events) == 0 +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_dont_fire_on_unknown_module(hass: HomeAssistant, lcn_connection) -> None: """Test for no event is fired if an input from an unknown module is received.""" inp = ModStatusAccessControl( diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 4d434c3c0eb0..8eadb76894e4 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -15,6 +15,8 @@ from homeassistant.helpers import device_registry as dr from .common import load_and_parse_node_fixture, setup_integration_with_node_fixture +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_single_node_device( hass: HomeAssistant, matter_client: MagicMock, @@ -42,6 +44,8 @@ async def test_device_registry_single_node_device( assert entry.sw_version == "v1.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_single_node_device_alt( hass: HomeAssistant, matter_client: MagicMock, @@ -113,6 +117,8 @@ async def test_device_registry_bridge( assert device2_entry.sw_version == "1.49.1" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_node_added_subscription( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index fcc4ed28ce0d..6575cb8fdb2c 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call from aiohttp import ClientWebSocketResponse from matter_server.common.errors import InvalidCommand, NodeCommissionFailed +import pytest from homeassistant.components.matter.api import ID, TYPE from homeassistant.core import HomeAssistant @@ -11,6 +12,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_commission( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], @@ -51,6 +54,8 @@ async def test_commission( matter_client.commission_with_code.assert_called_once_with("12345678") +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_commission_on_network( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], @@ -91,6 +96,8 @@ async def test_commission_on_network( matter_client.commission_on_network.assert_called_once_with(1234) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_set_thread_dataset( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], @@ -131,6 +138,8 @@ async def test_set_thread_dataset( matter_client.set_thread_operational_dataset.assert_called_once_with("test_dataset") +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_set_wifi_credentials( hass: HomeAssistant, hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 172290125b8d..743619ddde9a 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -23,6 +23,8 @@ async def contact_sensor_node_fixture( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_contact_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -53,6 +55,8 @@ async def occupancy_sensor_node_fixture( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_occupancy_sensor( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_diagnostics.py b/tests/components/matter/test_diagnostics.py index c79d6814df11..303e9879c563 100644 --- a/tests/components/matter/test_diagnostics.py +++ b/tests/components/matter/test_diagnostics.py @@ -56,6 +56,8 @@ async def test_matter_attribute_redact(device_diagnostics: dict[str, Any]) -> No assert redacted_device_diagnostics == device_diagnostics +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -74,6 +76,8 @@ async def test_config_entry_diagnostics( assert diagnostics == config_entry_diagnostics_redacted +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/matter/test_helpers.py b/tests/components/matter/test_helpers.py index 2ccb818b3334..28f4479432c6 100644 --- a/tests/components/matter/test_helpers.py +++ b/tests/components/matter/test_helpers.py @@ -18,6 +18,8 @@ from .common import setup_integration_with_node_fixture from tests.common import MockConfigEntry +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_device_id( hass: HomeAssistant, matter_client: MagicMock, @@ -31,6 +33,8 @@ async def test_get_device_id( assert device_id == "00000000000004D2-0000000000000005-MatterNodeDevice" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_get_node_from_device_entry( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index fac5653f86d4..aea52cc30793 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -81,6 +81,8 @@ async def test_entry_setup_unload( assert entity_state.state == STATE_UNAVAILABLE +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_home_assistant_stop( hass: HomeAssistant, matter_client: MagicMock, @@ -408,6 +410,8 @@ async def test_update_addon( assert update_addon.call_count == update_calls +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_issue_registry_invalid_version( hass: HomeAssistant, matter_client: MagicMock, @@ -604,6 +608,8 @@ async def test_remove_entry( assert "Failed to uninstall the Matter Server add-on" in caplog.text +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_remove_config_entry_device( hass: HomeAssistant, matter_client: MagicMock, @@ -644,6 +650,8 @@ async def test_remove_config_entry_device( assert not hass.states.get(entity_id) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_remove_config_entry_device_no_node( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 226b22670e6b..ef8541120086 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -14,6 +14,8 @@ from .common import ( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( ("fixture", "entity_id"), [ @@ -90,6 +92,8 @@ async def test_on_off_light( matter_client.send_device_command.reset_mock() +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( ("fixture", "entity_id"), [ @@ -144,6 +148,8 @@ async def test_dimmable_light( matter_client.send_device_command.reset_mock() +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( ("fixture", "entity_id"), [ @@ -208,6 +214,8 @@ async def test_color_temperature_light( matter_client.send_device_command.reset_mock() +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( ("fixture", "entity_id"), [ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 24b6662108c6..a2e97e188f63 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -61,6 +61,8 @@ async def temperature_sensor_node_fixture( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( hass: HomeAssistant, matter_client: MagicMock, @@ -79,6 +81,8 @@ async def test_sensor_null_value( assert state.state == "unknown" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_flow_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -97,6 +101,8 @@ async def test_flow_sensor( assert state.state == "2.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_humidity_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -115,6 +121,8 @@ async def test_humidity_sensor( assert state.state == "40.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_light_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -133,6 +141,8 @@ async def test_light_sensor( assert state.state == "2.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_pressure_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -151,6 +161,8 @@ async def test_pressure_sensor( assert state.state == "101.0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_temperature_sensor( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index 524ea548152b..6fbe5d58f289 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -24,6 +24,8 @@ async def switch_node_fixture( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_turn_on( hass: HomeAssistant, matter_client: MagicMock, @@ -58,6 +60,8 @@ async def test_turn_on( assert state.state == "on" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_turn_off( hass: HomeAssistant, matter_client: MagicMock, diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index dcbd927249ed..0be71be1de58 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -40,6 +40,8 @@ async def async_setup_sdm(hass): await hass.async_block_till_done() +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Exercise authentication library creates valid credentials.""" @@ -92,6 +94,8 @@ async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> assert creds.scopes == SDM_SCOPES +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth_expired_token( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 097f8a1c9f1c..bd2c772bacb1 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import patch from pyotgw.vars import OTGW, OTGW_ABOUT +import pytest from homeassistant import setup from homeassistant.components.opentherm_gw.const import DOMAIN @@ -28,6 +29,8 @@ MOCK_CONFIG_ENTRY = MockConfigEntry( ) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_insert(hass: HomeAssistant) -> None: """Test that the device registry is initialized correctly.""" MOCK_CONFIG_ENTRY.add_to_hass(hass) @@ -46,6 +49,8 @@ async def test_device_registry_insert(hass: HomeAssistant) -> None: assert gw_dev.sw_version == VERSION_OLD +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 2f3e268177ba..0bc2d04b41af 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -168,6 +168,8 @@ async def test_no_servers_found( assert result["errors"]["base"] == "no_servers" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_single_available_server( hass: HomeAssistant, mock_plex_calls, current_request_with_host: None ) -> None: @@ -206,6 +208,8 @@ async def test_single_available_server( await hass.config_entries.async_unload(result["result"].entry_id) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_multiple_servers_with_selection( hass: HomeAssistant, mock_plex_calls, @@ -261,6 +265,8 @@ async def test_multiple_servers_with_selection( await hass.config_entries.async_unload(result["result"].entry_id) +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_adding_last_unconfigured_server( hass: HomeAssistant, mock_plex_calls, diff --git a/tests/components/snooz/test_fan.py b/tests/components/snooz/test_fan.py index cda4ce24db16..795525fdf713 100644 --- a/tests/components/snooz/test_fan.py +++ b/tests/components/snooz/test_fan.py @@ -148,6 +148,8 @@ async def test_transition_off(hass: HomeAssistant, snooz_fan_entity_id: str) -> assert ATTR_ASSUMED_STATE not in state.attributes +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_push_events( hass: HomeAssistant, mock_connected_snooz: SnoozFixture, snooz_fan_entity_id: str ) -> None: @@ -172,6 +174,8 @@ async def test_push_events( assert state.attributes[ATTR_ASSUMED_STATE] is True +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_restore_state( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: diff --git a/tests/components/snooz/test_init.py b/tests/components/snooz/test_init.py index 821bd3a95c53..a7a0566d7c61 100644 --- a/tests/components/snooz/test_init.py +++ b/tests/components/snooz/test_init.py @@ -1,11 +1,15 @@ """Test Snooz configuration.""" from __future__ import annotations +import pytest + from homeassistant.core import HomeAssistant from . import SnoozFixture +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_removing_entry_cleans_up_connections( hass: HomeAssistant, mock_connected_snooz: SnoozFixture ) -> None: @@ -16,6 +20,8 @@ async def test_removing_entry_cleans_up_connections( assert not mock_connected_snooz.device.is_connected +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_reloading_entry_cleans_up_connections( hass: HomeAssistant, mock_connected_snooz: SnoozFixture ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 61d5a70d9975..9c1eef3ffbd0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -257,9 +257,21 @@ def garbage_collection() -> None: gc.collect() +@pytest.fixture(autouse=True) +def expected_lingering_tasks() -> bool: + """Temporary ability to bypass test failures. + + Parametrize to True to bypass the pytest failure. + @pytest.mark.parametrize("expected_lingering_tasks", [True]) + + This should be removed when all lingering tasks have been cleaned up. + """ + return False + + @pytest.fixture(autouse=True) def verify_cleanup( - event_loop: asyncio.AbstractEventLoop, + event_loop: asyncio.AbstractEventLoop, expected_lingering_tasks: bool ) -> Generator[None, None, None]: """Verify that the test has cleaned up resources correctly.""" threads_before = frozenset(threading.enumerate()) @@ -278,7 +290,10 @@ def verify_cleanup( # before moving on to the next test. tasks = asyncio.all_tasks(event_loop) - tasks_before for task in tasks: - _LOGGER.warning("Linger task after test %r", task) + if expected_lingering_tasks: + _LOGGER.warning("Linger task after test %r", task) + else: + pytest.fail(f"Linger task after test {repr(task)}") task.cancel() if tasks: event_loop.run_until_complete(asyncio.wait(tasks)) From 14bf68ad035ec0a50a752b3aaa616f0c646667de Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Mar 2023 11:59:31 +0100 Subject: [PATCH 0283/1058] Cleanup expected_lingering_tasks in knx (#89279) --- tests/components/knx/test_climate.py | 11 ----------- tests/components/knx/test_cover.py | 3 --- 2 files changed, 14 deletions(-) diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 1784d912be0c..582f082eb93d 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -1,5 +1,4 @@ """Test KNX climate.""" -import pytest from homeassistant.components.climate import PRESET_ECO, PRESET_SLEEP, HVACMode from homeassistant.components.knx.schema import ClimateSchema @@ -17,8 +16,6 @@ RAW_FLOAT_21_0 = (0x0C, 0x1A) RAW_FLOAT_22_0 = (0x0C, 0x4C) -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_basic_temperature_set( hass: HomeAssistant, knx: KNXTestKit ) -> None: @@ -58,8 +55,6 @@ async def test_climate_basic_temperature_set( assert len(events) == 1 -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate hvac mode.""" events = async_capture_events(hass, "state_changed") @@ -113,8 +108,6 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_write("1/2/6", (0x01,)) -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_climate_preset_mode( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry ) -> None: @@ -182,8 +175,6 @@ async def test_climate_preset_mode( assert len(knx.xknx.devices) == 0 -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test update climate entity for KNX.""" events = async_capture_events(hass, "state_changed") @@ -229,8 +220,6 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/7") -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate command_value.""" await knx.setup_integration( diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index 066429a884ba..5aef38ea00ad 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -1,5 +1,4 @@ """Test KNX cover.""" -import pytest from homeassistant.components.knx.schema import CoverSchema from homeassistant.const import CONF_NAME, STATE_CLOSING @@ -10,8 +9,6 @@ from .conftest import KNXTestKit from tests.common import async_capture_events -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX cover basic.""" events = async_capture_events(hass, "state_changed") From 8c282e2b0d2de389523463299b34bae9f28617cd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 7 Mar 2023 13:24:41 +0100 Subject: [PATCH 0284/1058] Remove deprecated DSMR Reader YAML configuration (#89239) --- .../components/dsmr_reader/config_flow.py | 3 -- .../components/dsmr_reader/sensor.py | 30 +------------------ .../components/dsmr_reader/strings.json | 6 ---- .../dsmr_reader/test_config_flow.py | 19 +----------- 4 files changed, 2 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/dsmr_reader/config_flow.py b/homeassistant/components/dsmr_reader/config_flow.py index 2f08894d1257..44ff66636548 100644 --- a/homeassistant/components/dsmr_reader/config_flow.py +++ b/homeassistant/components/dsmr_reader/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Awaitable -import logging from typing import Any from homeassistant.core import HomeAssistant @@ -11,8 +10,6 @@ from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def _async_has_devices(_: HomeAssistant) -> bool: """MQTT is set as dependency, so that should be sufficient.""" diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 72e24c52724a..28dc0abb2dfe 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -3,42 +3,14 @@ from __future__ import annotations from homeassistant.components import mqtt from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry 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 homeassistant.util import slugify -from .const import DOMAIN from .definitions import SENSORS, DSMRReaderSensorEntityDescription -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up DSMR Reader sensors via configuration.yaml and show deprecation warning.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( _: HomeAssistant, config_entry: ConfigEntry, diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json index 17e28cca8840..73c4ac044027 100644 --- a/homeassistant/components/dsmr_reader/strings.json +++ b/homeassistant/components/dsmr_reader/strings.json @@ -8,11 +8,5 @@ "description": "Make sure to configure the 'split topic' data sources in DSMR Reader." } } - }, - "issues": { - "deprecated_yaml": { - "title": "The DSMR Reader configuration is being removed", - "description": "Configuring DSMR Reader using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the DSMR Reader YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/dsmr_reader/test_config_flow.py b/tests/components/dsmr_reader/test_config_flow.py index edf5518e07b6..42d18d866a90 100644 --- a/tests/components/dsmr_reader/test_config_flow.py +++ b/tests/components/dsmr_reader/test_config_flow.py @@ -1,27 +1,10 @@ """Tests for the config flow.""" from homeassistant.components.dsmr_reader.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 -async def test_import_step(hass: HomeAssistant) -> None: - """Test the import step.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "DSMR Reader" - - second_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - ) - assert second_result["type"] == FlowResultType.ABORT - assert second_result["reason"] == "single_instance_allowed" - - async def test_user_step(hass: HomeAssistant) -> None: """Test the user step call.""" result = await hass.config_entries.flow.async_init( From ff2a88b42663ce26d286e348d859b6e59ddb4e5d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Mar 2023 13:25:31 +0100 Subject: [PATCH 0285/1058] Bump ruff to 0.0.254 (#89273) --- .pre-commit-config.yaml | 2 +- homeassistant/components/esphome/bluetooth/client.py | 2 ++ pyproject.toml | 2 ++ requirements_test_pre_commit.txt | 2 +- 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 357e2663fcc8..269d786ab246 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.253 + rev: v0.0.254 hooks: - id: ruff args: diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 6ec51cabeb23..343847f55fa0 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -526,6 +526,7 @@ class ESPHomeClient(BaseBleakClient): The characteristic to read from, specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. + **kwargs: Unused Returns: (bytearray) The read data. @@ -542,6 +543,7 @@ class ESPHomeClient(BaseBleakClient): Args: handle (int): The handle of the descriptor to read from. + **kwargs: Unused Returns: (bytearray) The read data. diff --git a/pyproject.toml b/pyproject.toml index 850995273bcf..bc7a603e5e2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -272,6 +272,8 @@ ignore = [ "D407", # Section name underlining "E501", # line too long "E731", # do not assign a lambda expression, use a def + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` ] [tool.ruff.flake8-import-conventions.extend-aliases] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index e2deb067d7d4..65913a315965 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -14,5 +14,5 @@ pycodestyle==2.10.0 pydocstyle==6.2.3 pyflakes==3.0.1 pyupgrade==3.3.1 -ruff==0.0.253 +ruff==0.0.254 yamllint==1.28.0 From 3f061e91015826a1b9146b830407633863c5ca10 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:15:26 +0100 Subject: [PATCH 0286/1058] Drop deepcopy of manual mqtt alarm control panel config (#89287) --- .../components/manual_mqtt/alarm_control_panel.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 3857dd195428..d6b4a58c4130 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -1,7 +1,6 @@ """Support for manual alarms controllable via MQTT.""" from __future__ import annotations -import copy import datetime import logging import re @@ -87,15 +86,18 @@ ATTR_POST_PENDING_STATE = "post_pending_state" def _state_validator(config): """Validate the state.""" - config = copy.deepcopy(config) for state in SUPPORTED_PRETRIGGER_STATES: if CONF_DELAY_TIME not in config[state]: - config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + config[state] = config[state] | {CONF_DELAY_TIME: config[CONF_DELAY_TIME]} if CONF_TRIGGER_TIME not in config[state]: - config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] + config[state] = config[state] | { + CONF_TRIGGER_TIME: config[CONF_TRIGGER_TIME] + } for state in SUPPORTED_PENDING_STATES: if CONF_PENDING_TIME not in config[state]: - config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME] + config[state] = config[state] | { + CONF_PENDING_TIME: config[CONF_PENDING_TIME] + } return config From f48b535d9d9a4fbb5e21aed2e0047d67f940fcf3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:15:48 +0100 Subject: [PATCH 0287/1058] Drop deepcopy of manual alarm control panel config (#89286) --- homeassistant/components/manual/alarm_control_panel.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index d35f9b73ef36..f0436ba1d698 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -1,7 +1,6 @@ """Support for manual alarms.""" from __future__ import annotations -import copy import datetime import logging import re @@ -74,15 +73,16 @@ ATTR_NEXT_STATE = "next_state" def _state_validator(config): """Validate the state.""" - config = copy.deepcopy(config) for state in SUPPORTED_PRETRIGGER_STATES: if CONF_DELAY_TIME not in config[state]: - config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME] + config[state] = config[state] | {CONF_DELAY_TIME: config[CONF_DELAY_TIME]} if CONF_TRIGGER_TIME not in config[state]: - config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME] + config[state] = config[state] | { + CONF_TRIGGER_TIME: config[CONF_TRIGGER_TIME] + } for state in SUPPORTED_ARMING_STATES: if CONF_ARMING_TIME not in config[state]: - config[state][CONF_ARMING_TIME] = config[CONF_ARMING_TIME] + config[state] = config[state] | {CONF_ARMING_TIME: config[CONF_ARMING_TIME]} return config From f5a3c4f7f55406eb9dc52a73e6b2e94b3f4d5182 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:16:24 +0100 Subject: [PATCH 0288/1058] Drop deepcopy of intent_script config (#89285) --- homeassistant/components/intent_script/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 128c9332aebc..2ec898bfb0eb 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -1,7 +1,6 @@ """Handle intents with scripts.""" from __future__ import annotations -import copy import logging import voluptuous as vol @@ -57,8 +56,8 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Activate Alexa component.""" - intents = copy.deepcopy(config[DOMAIN]) + """Set up the intent script component.""" + intents = config[DOMAIN] template.attach(hass, intents) for intent_type, conf in intents.items(): From 058bb4c3e668b15b0b45249664de15a0827b4e53 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:16:46 +0100 Subject: [PATCH 0289/1058] Drop deepcopy of Alexa config (#89284) --- homeassistant/components/alexa/flash_briefings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 1521afcae5a2..6f53d86d4444 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -1,5 +1,4 @@ """Support for Alexa skill service end point.""" -import copy import hmac from http import HTTPStatus import logging @@ -48,7 +47,7 @@ class AlexaFlashBriefingView(http.HomeAssistantView): def __init__(self, hass, flash_briefings): """Initialize Alexa view.""" super().__init__() - self.flash_briefings = copy.deepcopy(flash_briefings) + self.flash_briefings = flash_briefings template.attach(hass, self.flash_briefings) @callback From bc0b3abb0104509efe17f66e7f21f900dd389750 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 16:54:35 +0100 Subject: [PATCH 0290/1058] Remove unittest.TestCase from service helper tests (#89283) * Remove unittest.TestCase from service helper tests * Update * Improve tests --- tests/helpers/test_service.py | 344 +++++++++++++++++----------------- 1 file changed, 177 insertions(+), 167 deletions(-) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index b93562621c31..c3b5165bb142 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,7 +1,6 @@ """Test service helpers.""" from collections import OrderedDict from copy import deepcopy -import unittest from unittest.mock import AsyncMock, Mock, patch import pytest @@ -33,10 +32,8 @@ from tests.common import ( MockEntity, MockUser, async_mock_service, - get_test_home_assistant, mock_device_registry, mock_registry, - mock_service, ) SUPPORT_A = 1 @@ -226,181 +223,194 @@ def area_mock(hass): ) -class TestServiceHelpers(unittest.TestCase): - """Test the Home Assistant service helpers.""" +async def test_call_from_config(hass: HomeAssistant): + """Test the sync wrapper of service.async_call_from_config.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service_template": "{{ 'test_domain.test_service' }}", + "entity_id": "hello.world", + "data": {"hello": "goodbye"}, + } - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.calls = mock_service(self.hass, "test_domain", "test_service") + await hass.async_add_executor_job(service.call_from_config, hass, config) + await hass.async_block_till_done() - def tearDown(self): # pylint: disable=invalid-name - """Stop down everything that was started.""" - self.hass.stop() + assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} - def test_service_call(self): - """Test service call with templating.""" - config = { - "service": "{{ 'test_domain.test_service' }}", - "entity_id": "hello.world", - "data": { - "hello": "{{ 'goodbye' }}", - "effect": {"value": "{{ 'complex' }}", "simple": "simple"}, + +async def test_service_call(hass: HomeAssistant): + """Test service call with templating.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service": "{{ 'test_domain.test_service' }}", + "entity_id": "hello.world", + "data": { + "hello": "{{ 'goodbye' }}", + "effect": {"value": "{{ 'complex' }}", "simple": "simple"}, + }, + "data_template": {"list": ["{{ 'list' }}", "2"]}, + "target": {"area_id": "test-area-id", "entity_id": "will.be_overridden"}, + } + + await service.async_call_from_config(hass, config) + await hass.async_block_till_done() + + assert dict(calls[0].data) == { + "hello": "goodbye", + "effect": { + "value": "complex", + "simple": "simple", + }, + "list": ["list", "2"], + "entity_id": ["hello.world"], + "area_id": ["test-area-id"], + } + + config = { + "service": "{{ 'test_domain.test_service' }}", + "target": { + "area_id": ["area-42", "{{ 'area-51' }}"], + "device_id": ["abcdef", "{{ 'fedcba' }}"], + "entity_id": ["light.static", "{{ 'light.dynamic' }}"], + }, + } + + await service.async_call_from_config(hass, config) + await hass.async_block_till_done() + + assert dict(calls[1].data) == { + "area_id": ["area-42", "area-51"], + "device_id": ["abcdef", "fedcba"], + "entity_id": ["light.static", "light.dynamic"], + } + + config = { + "service": "{{ 'test_domain.test_service' }}", + "target": "{{ var_target }}", + } + + await service.async_call_from_config( + hass, + config, + variables={ + "var_target": { + "entity_id": "light.static", + "area_id": ["area-42", "area-51"], }, - "data_template": {"list": ["{{ 'list' }}", "2"]}, - "target": {"area_id": "test-area-id", "entity_id": "will.be_overridden"}, + }, + ) + await hass.async_block_till_done() + + assert dict(calls[2].data) == { + "area_id": ["area-42", "area-51"], + "entity_id": ["light.static"], + } + + +async def test_service_template_service_call(hass: HomeAssistant): + """Test legacy service_template call with templating.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service_template": "{{ 'test_domain.test_service' }}", + "entity_id": "hello.world", + "data": {"hello": "goodbye"}, + } + + await service.async_call_from_config(hass, config) + await hass.async_block_till_done() + + assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} + + +async def test_passing_variables_to_templates(hass: HomeAssistant): + """Test passing variables to templates.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service_template": "{{ var_service }}", + "entity_id": "hello.world", + "data_template": {"hello": "{{ var_data }}"}, + } + + await service.async_call_from_config( + hass, + config, + variables={ + "var_service": "test_domain.test_service", + "var_data": "goodbye", + }, + ) + await hass.async_block_till_done() + + assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} + + +async def test_bad_template(hass: HomeAssistant): + """Test passing bad template.""" + calls = async_mock_service(hass, "test_domain", "test_service") + config = { + "service_template": "{{ var_service }}", + "entity_id": "hello.world", + "data_template": {"hello": "{{ states + unknown_var }}"}, + } + + await service.async_call_from_config( + hass, + config, + variables={ + "var_service": "test_domain.test_service", + "var_data": "goodbye", + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + +async def test_split_entity_string(hass: HomeAssistant): + """Test splitting of entity string.""" + calls = async_mock_service(hass, "test_domain", "test_service") + await service.async_call_from_config( + hass, + { + "service": "test_domain.test_service", + "entity_id": "hello.world, sensor.beer", + }, + ) + await hass.async_block_till_done() + assert ["hello.world", "sensor.beer"] == calls[-1].data.get("entity_id") + + +async def test_not_mutate_input(hass: HomeAssistant): + """Test for immutable input.""" + async_mock_service(hass, "test_domain", "test_service") + config = cv.SERVICE_SCHEMA( + { + "service": "test_domain.test_service", + "entity_id": "hello.world, sensor.beer", + "data": {"hello": 1}, + "data_template": {"nested": {"value": "{{ 1 + 1 }}"}}, } + ) + orig = deepcopy(config) - service.call_from_config(self.hass, config) - self.hass.block_till_done() + # Only change after call is each template getting hass attached + template.attach(hass, orig) - assert dict(self.calls[0].data) == { - "hello": "goodbye", - "effect": { - "value": "complex", - "simple": "simple", - }, - "list": ["list", "2"], - "entity_id": ["hello.world"], - "area_id": ["test-area-id"], - } + await service.async_call_from_config(hass, config, validate_config=False) + assert orig == config - config = { - "service": "{{ 'test_domain.test_service' }}", - "target": { - "area_id": ["area-42", "{{ 'area-51' }}"], - "device_id": ["abcdef", "{{ 'fedcba' }}"], - "entity_id": ["light.static", "{{ 'light.dynamic' }}"], - }, - } - service.call_from_config(self.hass, config) - self.hass.block_till_done() +@patch("homeassistant.helpers.service._LOGGER.error") +async def test_fail_silently_if_no_service(mock_log, hass: HomeAssistant): + """Test failing if service is missing.""" + await service.async_call_from_config(hass, None) + assert mock_log.call_count == 1 - assert dict(self.calls[1].data) == { - "area_id": ["area-42", "area-51"], - "device_id": ["abcdef", "fedcba"], - "entity_id": ["light.static", "light.dynamic"], - } + await service.async_call_from_config(hass, {}) + assert mock_log.call_count == 2 - config = { - "service": "{{ 'test_domain.test_service' }}", - "target": "{{ var_target }}", - } - - service.call_from_config( - self.hass, - config, - variables={ - "var_target": { - "entity_id": "light.static", - "area_id": ["area-42", "area-51"], - }, - }, - ) - - service.call_from_config(self.hass, config) - self.hass.block_till_done() - - assert dict(self.calls[2].data) == { - "area_id": ["area-42", "area-51"], - "entity_id": ["light.static"], - } - - def test_service_template_service_call(self): - """Test legacy service_template call with templating.""" - config = { - "service_template": "{{ 'test_domain.test_service' }}", - "entity_id": "hello.world", - "data": {"hello": "goodbye"}, - } - - service.call_from_config(self.hass, config) - self.hass.block_till_done() - - assert self.calls[0].data["hello"] == "goodbye" - - def test_passing_variables_to_templates(self): - """Test passing variables to templates.""" - config = { - "service_template": "{{ var_service }}", - "entity_id": "hello.world", - "data_template": {"hello": "{{ var_data }}"}, - } - - service.call_from_config( - self.hass, - config, - variables={ - "var_service": "test_domain.test_service", - "var_data": "goodbye", - }, - ) - self.hass.block_till_done() - - assert self.calls[0].data["hello"] == "goodbye" - - def test_bad_template(self): - """Test passing bad template.""" - config = { - "service_template": "{{ var_service }}", - "entity_id": "hello.world", - "data_template": {"hello": "{{ states + unknown_var }}"}, - } - - service.call_from_config( - self.hass, - config, - variables={ - "var_service": "test_domain.test_service", - "var_data": "goodbye", - }, - ) - self.hass.block_till_done() - - assert len(self.calls) == 0 - - def test_split_entity_string(self): - """Test splitting of entity string.""" - service.call_from_config( - self.hass, - { - "service": "test_domain.test_service", - "entity_id": "hello.world, sensor.beer", - }, - ) - self.hass.block_till_done() - assert ["hello.world", "sensor.beer"] == self.calls[-1].data.get("entity_id") - - def test_not_mutate_input(self): - """Test for immutable input.""" - config = cv.SERVICE_SCHEMA( - { - "service": "test_domain.test_service", - "entity_id": "hello.world, sensor.beer", - "data": {"hello": 1}, - "data_template": {"nested": {"value": "{{ 1 + 1 }}"}}, - } - ) - orig = deepcopy(config) - - # Only change after call is each template getting hass attached - template.attach(self.hass, orig) - - service.call_from_config(self.hass, config, validate_config=False) - assert orig == config - - @patch("homeassistant.helpers.service._LOGGER.error") - def test_fail_silently_if_no_service(self, mock_log): - """Test failing if service is missing.""" - service.call_from_config(self.hass, None) - assert mock_log.call_count == 1 - - service.call_from_config(self.hass, {}) - assert mock_log.call_count == 2 - - service.call_from_config(self.hass, {"service": "invalid"}) - assert mock_log.call_count == 3 + await service.async_call_from_config(hass, {"service": "invalid"}) + assert mock_log.call_count == 3 async def test_service_call_entry_id( From f9a59c0839a3e8c56ad7f34a40144bce4bd49b23 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Mar 2023 19:04:50 +0100 Subject: [PATCH 0291/1058] Ignore DSL entities if SFR box is not adsl (#89291) --- homeassistant/components/sfr_box/__init__.py | 18 +++++++++--------- homeassistant/components/sfr_box/sensor.py | 18 ++++++++---------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index 07f122fa4b2d..4873acf753e5 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -1,13 +1,11 @@ """SFR Box.""" from __future__ import annotations -import asyncio - from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -40,15 +38,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, box, "system", lambda b: b.system_get_info() ), ) - tasks = [ - data.dsl.async_config_entry_first_refresh(), - data.system.async_config_entry_first_refresh(), - ] - await asyncio.gather(*tasks) + await data.system.async_config_entry_first_refresh() + system_info = data.system.data + + if system_info.net_infra == "adsl": + await data.dsl.async_config_entry_first_refresh() + else: + platforms = list(platforms) + platforms.remove(Platform.BINARY_SENSOR) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data - system_info = data.system.data device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index f84441d24914..5f4aadce7e20 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -1,7 +1,6 @@ """SFR Box sensor platform.""" -from collections.abc import Callable, Iterable +from collections.abc import Callable from dataclasses import dataclass -from itertools import chain from typing import Generic, TypeVar from sfrbox_api.models import DslInfo, SystemInfo @@ -204,16 +203,15 @@ async def async_setup_entry( """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] - entities: Iterable[SFRBoxSensor] = chain( - ( + entities: list[SFRBoxSensor] = [ + SFRBoxSensor(data.system, description, data.system.data) + for description in SYSTEM_SENSOR_TYPES + ] + if data.system.data.net_infra == "adsl": + entities.extend( SFRBoxSensor(data.dsl, description, data.system.data) for description in DSL_SENSOR_TYPES - ), - ( - SFRBoxSensor(data.system, description, data.system.data) - for description in SYSTEM_SENSOR_TYPES - ), - ) + ) async_add_entities(entities) From 2d3c5cf8eef7f2b653098fe0b90a73feb2b02052 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 7 Mar 2023 20:29:38 +0100 Subject: [PATCH 0292/1058] Reolink test init 100% (#89112) * Split out reolink tests * Bring __init__ coverage to 100% * Improve docstrings * Use patching and autospec=True for ReolinkHost * Use fixture * fix styling * Parametrize tests * Update tests/components/reolink/conftest.py Co-authored-by: Franck Nijhof * Apply suggestions from code review Co-authored-by: Franck Nijhof * Update test_config_flow.py * convert to fixture * review comments * Update tests/components/reolink/conftest.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/reolink/conftest.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/reolink/conftest.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * fix tests * fix imports * Update test_init.py * Check if host is logout on reload --------- Co-authored-by: Franck Nijhof Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 1 - tests/components/reolink/conftest.py | 92 +++++++ tests/components/reolink/test_config_flow.py | 240 ++++++------------- tests/components/reolink/test_init.py | 116 +++++++++ 4 files changed, 282 insertions(+), 167 deletions(-) create mode 100644 tests/components/reolink/conftest.py create mode 100644 tests/components/reolink/test_init.py diff --git a/.coveragerc b/.coveragerc index f38c6226ac82..fa6ae5ba0d22 100644 --- a/.coveragerc +++ b/.coveragerc @@ -975,7 +975,6 @@ omit = homeassistant/components/rejseplanen/sensor.py homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote_rpi_gpio/* - homeassistant/components/reolink/__init__.py homeassistant/components/reolink/binary_sensor.py homeassistant/components/reolink/button.py homeassistant/components/reolink/camera.py diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py new file mode 100644 index 000000000000..941a1ca7c878 --- /dev/null +++ b/tests/components/reolink/conftest.py @@ -0,0 +1,92 @@ +"""Setup the Reolink tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.reolink import const +from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_HOST2 = "4.5.6.7" +TEST_USERNAME = "admin" +TEST_USERNAME2 = "username" +TEST_PASSWORD = "password" +TEST_PASSWORD2 = "new_password" +TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_PORT = 1234 +TEST_NVR_NAME = "test_reolink_name" +TEST_USE_HTTPS = True + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.reolink.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None]: + """Mock reolink connection.""" + with patch( + "homeassistant.components.reolink.host.webhook.async_register", + return_value=True, + ), patch( + "homeassistant.components.reolink.host.Host", autospec=True + ) as host_mock_class: + host_mock = host_mock_class.return_value + host_mock.get_host_data.return_value = None + host_mock.get_states.return_value = None + host_mock.check_new_firmware.return_value = False + host_mock.unsubscribe.return_value = True + host_mock.logout.return_value = True + host_mock.mac_address = TEST_MAC + host_mock.onvif_enabled = True + host_mock.rtmp_enabled = True + host_mock.rtsp_enabled = True + host_mock.nvr_name = TEST_NVR_NAME + host_mock.port = TEST_PORT + host_mock.use_https = TEST_USE_HTTPS + host_mock.is_admin = True + host_mock.user_level = "admin" + host_mock.sw_version_update_required = False + host_mock.timeout = 60 + host_mock.renewtimer = 600 + yield host_mock + + +@pytest.fixture +def reolink_platforms(mock_get_source_ip: None) -> Generator[None, None, None]: + """Mock reolink entry setup.""" + with patch("homeassistant.components.reolink.PLATFORMS", return_value=[]): + yield + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add the reolink mock config entry to hass.""" + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index a5de5d5acb81..b3abb793a9f7 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Reolink config flow.""" import json -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import MagicMock import pytest from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkError @@ -9,61 +9,26 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.config import async_process_ha_core_config from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac +from .conftest import ( + TEST_HOST, + TEST_HOST2, + TEST_MAC, + TEST_NVR_NAME, + TEST_PASSWORD, + TEST_PASSWORD2, + TEST_PORT, + TEST_USE_HTTPS, + TEST_USERNAME, + TEST_USERNAME2, +) + from tests.common import MockConfigEntry -TEST_HOST = "1.2.3.4" -TEST_HOST2 = "4.5.6.7" -TEST_USERNAME = "admin" -TEST_USERNAME2 = "username" -TEST_PASSWORD = "password" -TEST_PASSWORD2 = "new_password" -TEST_MAC = "ab:cd:ef:gh:ij:kl" -TEST_PORT = 1234 -TEST_NVR_NAME = "test_reolink_name" -TEST_USE_HTTPS = True - - -def get_mock_info(error=None, user_level="admin"): - """Return a mock gateway info instance.""" - host_mock = Mock() - if error is None: - host_mock.get_host_data = AsyncMock(return_value=None) - else: - host_mock.get_host_data = AsyncMock(side_effect=error) - host_mock.check_new_firmware = AsyncMock(return_value=False) - host_mock.unsubscribe = AsyncMock(return_value=True) - host_mock.logout = AsyncMock(return_value=True) - host_mock.mac_address = TEST_MAC - host_mock.onvif_enabled = True - host_mock.rtmp_enabled = True - host_mock.rtsp_enabled = True - host_mock.nvr_name = TEST_NVR_NAME - host_mock.port = TEST_PORT - host_mock.use_https = TEST_USE_HTTPS - host_mock.is_admin = user_level == "admin" - host_mock.user_level = user_level - host_mock.timeout = 60 - host_mock.renewtimer = 600 - host_mock.get_states = AsyncMock(return_value=None) - return host_mock - - -@pytest.fixture(name="reolink_connect", autouse=True) -def reolink_connect_fixture(mock_get_source_ip): - """Mock reolink connection and entry setup.""" - with patch( - "homeassistant.components.reolink.host.webhook.async_register", - return_value=True, - ), patch("homeassistant.components.reolink.PLATFORMS", return_value=[]), patch( - "homeassistant.components.reolink.host.Host", return_value=get_mock_info() - ): - yield +pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") async def test_config_flow_manual_success(hass: HomeAssistant) -> None: @@ -99,7 +64,9 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None: } -async def test_config_flow_errors(hass: HomeAssistant) -> None: +async def test_config_flow_errors( + hass: HomeAssistant, reolink_connect: MagicMock +) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -109,81 +76,82 @@ async def test_config_flow_errors(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - host_mock = get_mock_info(error=ReolinkError("Test error")) - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) - - assert result["type"] is data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {CONF_HOST: "cannot_connect"} - - host_mock = get_mock_info(user_level="guest") - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) + reolink_connect.is_admin = False + reolink_connect.user_level = "guest" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_USERNAME: "not_admin"} - host_mock = get_mock_info(error=json.JSONDecodeError("test_error", "test", 1)) - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) + reolink_connect.is_admin = True + reolink_connect.user_level = "admin" + reolink_connect.get_host_data.side_effect = ReolinkError("Test error") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_HOST: "cannot_connect"} + + reolink_connect.get_host_data.side_effect = json.JSONDecodeError( + "test_error", "test", 1 + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "unknown"} - host_mock = get_mock_info(error=CredentialsInvalidError("Test error")) - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) + reolink_connect.get_host_data.side_effect = CredentialsInvalidError("Test error") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "invalid_auth"} - host_mock = get_mock_info(error=ApiError("Test error")) - with patch("homeassistant.components.reolink.host.Host", return_value=host_mock): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_HOST: TEST_HOST, - }, - ) + reolink_connect.get_host_data.side_effect = ApiError("Test error") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) assert result["type"] is data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "api_error"} + reolink_connect.get_host_data.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -422,63 +390,3 @@ async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: assert result["type"] is data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_http_no_repair_issue(hass: HomeAssistant) -> None: - """Test no repairs issue is raised when http local url is used.""" - config_entry = MockConfigEntry( - domain=const.DOMAIN, - unique_id=format_mac(TEST_MAC), - data={ - CONF_HOST: TEST_HOST, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, - }, - options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, - }, - title=TEST_NVR_NAME, - ) - config_entry.add_to_hass(hass) - - await async_process_ha_core_config( - hass, {"country": "GB", "internal_url": "http://test_homeassistant_address"} - ) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 0 - - -async def test_https_repair_issue(hass: HomeAssistant) -> None: - """Test repairs issue is raised when https local url is used.""" - config_entry = MockConfigEntry( - domain=const.DOMAIN, - unique_id=format_mac(TEST_MAC), - data={ - CONF_HOST: TEST_HOST, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, - }, - options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, - }, - title=TEST_NVR_NAME, - ) - config_entry.add_to_hass(hass) - - await async_process_ha_core_config( - hass, {"country": "GB", "internal_url": "https://test_homeassistant_address"} - ) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py new file mode 100644 index 000000000000..035bfa6e5389 --- /dev/null +++ b/tests/components/reolink/test_init.py @@ -0,0 +1,116 @@ +"""Test the Reolink init.""" +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock + +import pytest +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.reolink import const +from homeassistant.config import async_process_ha_core_config +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") + + +@pytest.mark.parametrize( + ("attr", "value", "expected"), + [ + ( + "is_admin", + False, + ConfigEntryState.SETUP_ERROR, + ), + ( + "get_host_data", + AsyncMock(side_effect=ReolinkError("Test error")), + ConfigEntryState.SETUP_RETRY, + ), + ( + "get_host_data", + AsyncMock(side_effect=ValueError("Test error")), + ConfigEntryState.SETUP_ERROR, + ), + ( + "get_states", + AsyncMock(side_effect=ReolinkError("Test error")), + ConfigEntryState.SETUP_RETRY, + ), + ( + "supported", + Mock(return_value=False), + ConfigEntryState.LOADED, + ), + ( + "check_new_firmware", + AsyncMock(side_effect=ReolinkError("Test error")), + ConfigEntryState.LOADED, + ), + ], +) +async def test_failures_parametrized( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + attr: str, + value: Any, + expected: ConfigEntryState, +) -> None: + """Test outcomes when changing errors.""" + setattr(reolink_connect, attr, value) + assert await hass.config_entries.async_setup(config_entry.entry_id) is ( + expected == ConfigEntryState.LOADED + ) + await hass.async_block_till_done() + + assert config_entry.state == expected + + +async def test_entry_reloading( + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock +) -> None: + """Test the entry is reloaded correctly when settings change.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert reolink_connect.logout.call_count == 0 + assert config_entry.title == "test_reolink_name" + + hass.config_entries.async_update_entry(config_entry, title="New Name") + await hass.async_block_till_done() + + assert reolink_connect.logout.call_count == 1 + assert config_entry.title == "New Name" + + +async def test_http_no_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test no repairs issue is raised when http local url is used.""" + await async_process_ha_core_config( + hass, {"country": "GB", "internal_url": "http://test_homeassistant_address"} + ) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "https_webhook") not in issue_registry.issues + + +async def test_https_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test repairs issue is raised when https local url is used.""" + await async_process_ha_core_config( + hass, {"country": "GB", "internal_url": "https://test_homeassistant_address"} + ) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "https_webhook") in issue_registry.issues From aa2267d68ef3e975a6fb911abb372652b8356067 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Mar 2023 23:21:47 +0100 Subject: [PATCH 0293/1058] Rename hass context variable (#89302) --- homeassistant/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 8650b9a9e31b..3c34209cfbc1 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -149,7 +149,7 @@ MAX_EXPECTED_ENTITY_IDS = 16384 _LOGGER = logging.getLogger(__name__) -_cv_hass: ContextVar[HomeAssistant] = ContextVar("current_entry") +_cv_hass: ContextVar[HomeAssistant] = ContextVar("hass") @functools.lru_cache(MAX_EXPECTED_ENTITY_IDS) From 099f16f6b87d9be775787856e9a8a2e439dd5ea8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Mar 2023 15:19:08 -1000 Subject: [PATCH 0294/1058] Fix missing f-string in async_listen (#89336) --- homeassistant/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 3c34209cfbc1..e8fb41be32dd 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1042,7 +1042,7 @@ class EventBus: return self._async_listen_filterable_job( event_type, _FilterableJob( - HassJob(listener, "listen {event_type}"), event_filter, run_immediately + HassJob(listener, f"listen {event_type}"), event_filter, run_immediately ), ) From bde40cde48b96b7bba2484387fdf651821cf9b7a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Mar 2023 15:21:26 -1000 Subject: [PATCH 0295/1058] Fix thread diagnostics loading blocking the event loop (#89307) * Fix thread diagnostics loading blocking the event loop * patch target --- .../components/thread/diagnostics.py | 98 +++++++++++-------- tests/components/thread/test_diagnostics.py | 4 +- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index b945f818d008..eb1e2a5ef681 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -17,9 +17,8 @@ some of their thread accessories can't be pinged, but it's still a thread proble from __future__ import annotations -from typing import Any, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict -from pyroute2 import NDB # pylint: disable=no-name-in-module from python_otbr_api.tlv_parser import MeshcopTLVType from homeassistant.components import zeroconf @@ -29,6 +28,9 @@ from homeassistant.core import HomeAssistant from .dataset_store import async_get_store from .discovery import async_read_zeroconf_cache +if TYPE_CHECKING: + from pyroute2 import NDB # pylint: disable=no-name-in-module + class Neighbour(TypedDict): """A neighbour cache entry (ip neigh).""" @@ -67,58 +69,69 @@ class Network(TypedDict): unexpected_routers: set[str] -def _get_possible_thread_routes() -> ( - tuple[dict[str, dict[str, Route]], dict[str, set[str]]] -): +def _get_possible_thread_routes( + ndb: NDB, +) -> tuple[dict[str, dict[str, Route]], dict[str, set[str]]]: # Build a list of possible thread routes # Right now, this is ipv6 /64's that have a gateway # We cross reference with zerconf data to confirm which via's are known border routers routes: dict[str, dict[str, Route]] = {} reverse_routes: dict[str, set[str]] = {} - with NDB() as ndb: - for record in ndb.routes: - # Limit to IPV6 routes - if record.family != 10: - continue - # Limit to /64 prefixes - if record.dst_len != 64: - continue - # Limit to routes with a via - if not record.gateway and not record.nh_gateway: - continue - gateway = record.gateway or record.nh_gateway - route = routes.setdefault(gateway, {}) - route[record.dst] = { - "metrics": record.metrics, - "priority": record.priority, - # NM creates "nexthop" routes - a single route with many via's - # Kernel creates many routes with a single via - "is_nexthop": record.nh_gateway is not None, - } - reverse_routes.setdefault(record.dst, set()).add(gateway) + for record in ndb.routes: + # Limit to IPV6 routes + if record.family != 10: + continue + # Limit to /64 prefixes + if record.dst_len != 64: + continue + # Limit to routes with a via + if not record.gateway and not record.nh_gateway: + continue + gateway = record.gateway or record.nh_gateway + route = routes.setdefault(gateway, {}) + route[record.dst] = { + "metrics": record.metrics, + "priority": record.priority, + # NM creates "nexthop" routes - a single route with many via's + # Kernel creates many routes with a single via + "is_nexthop": record.nh_gateway is not None, + } + reverse_routes.setdefault(record.dst, set()).add(gateway) return routes, reverse_routes -def _get_neighbours() -> dict[str, Neighbour]: - neighbours: dict[str, Neighbour] = {} - - with NDB() as ndb: - for record in ndb.neighbours: - neighbours[record.dst] = { - "lladdr": record.lladdr, - "state": record.state, - "probes": record.probes, - } - +def _get_neighbours(ndb: NDB) -> dict[str, Neighbour]: + # Build a list of neighbours + neighbours: dict[str, Neighbour] = { + record.dst: { + "lladdr": record.lladdr, + "state": record.state, + "probes": record.probes, + } + for record in ndb.neighbours + } return neighbours +def _get_routes_and_neighbors(): + """Get the routes and neighbours from pyroute2.""" + # Import in the executor since import NDB can take a while + from pyroute2 import ( # pylint: disable=no-name-in-module, import-outside-toplevel + NDB, + ) + + with NDB() as ndb: # pylint: disable=not-callable + routes, reverse_routes = _get_possible_thread_routes(ndb) + neighbours = _get_neighbours(ndb) + + return routes, reverse_routes, neighbours + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for all known thread networks.""" - networks: dict[str, Network] = {} # Start with all networks that HA knows about @@ -140,13 +153,12 @@ async def async_get_config_entry_diagnostics( # Find all routes currently act that might be thread related, so we can match them to # border routers as we process the zeroconf data. - routes, reverse_routes = await hass.async_add_executor_job( - _get_possible_thread_routes + # + # Also find all neighbours + routes, reverse_routes, neighbours = await hass.async_add_executor_job( + _get_routes_and_neighbors ) - # Find all neighbours - neighbours = await hass.async_add_executor_job(_get_neighbours) - aiozc = await zeroconf.async_get_async_instance(hass) for data in async_read_zeroconf_cache(aiozc): if not data.extended_pan_id: diff --git a/tests/components/thread/test_diagnostics.py b/tests/components/thread/test_diagnostics.py index 1006fa374c35..a551315205b7 100644 --- a/tests/components/thread/test_diagnostics.py +++ b/tests/components/thread/test_diagnostics.py @@ -133,9 +133,7 @@ class MockNeighbour: @pytest.fixture def ndb() -> Mock: """Prevent NDB poking the OS route tables.""" - with patch( - "homeassistant.components.thread.diagnostics.NDB" - ) as ndb, ndb() as instance: + with patch("pyroute2.NDB") as ndb, ndb() as instance: instance.neighbours = [] instance.routes = [] yield instance From fa128fbcec46c711d38aad5718c4d31bffca1e09 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 7 Mar 2023 20:24:08 -0500 Subject: [PATCH 0296/1058] Clean ZHA radio path with trailing whitespace (#89299) * Clean config flow entries with trailing whitespace * Rewrite the config entry at runtime, without upgrading * Skip intermediate `data = config_entry.data` variable * Perform a deepcopy to ensure the config entry will actually be updated --- homeassistant/components/zha/__init__.py | 10 ++++++ tests/components/zha/test_init.py | 41 +++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index d32dcf0bda62..d0496fe7b60f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,5 +1,6 @@ """Support for Zigbee Home Automation devices.""" import asyncio +import copy import logging import os @@ -90,6 +91,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b Will automatically load components to support devices found on the network. """ + # Strip whitespace around `socket://` URIs, this is no longer accepted by zigpy + # This will be removed in 2023.7.0 + path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + data = copy.deepcopy(dict(config_entry.data)) + + if path.startswith("socket://") and path != path.strip(): + data[CONF_DEVICE][CONF_DEVICE_PATH] = path.strip() + hass.config_entries.async_update_entry(config_entry, data=data) + zha_data = hass.data.setdefault(DATA_ZHA, {}) config = zha_data.get(DATA_ZHA_CONFIG, {}) diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index e580242a677d..a92631f6da35 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,9 +1,10 @@ """Tests for ZHA integration init.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from homeassistant.components.zha import async_setup_entry from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, CONF_RADIO_TYPE, @@ -108,3 +109,41 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: ) as setup_mock: assert await async_setup_component(hass, DOMAIN, {DOMAIN: zha_config}) assert setup_mock.call_count == 1 + + +@pytest.mark.parametrize( + ("path", "cleaned_path"), + [ + ("/dev/path1", "/dev/path1"), + ("/dev/path1 ", "/dev/path1 "), + ("socket://dev/path1 ", "socket://dev/path1"), + ], +) +@patch("homeassistant.components.zha.setup_quirks", Mock(return_value=True)) +@patch("homeassistant.components.zha.api.async_load_api", Mock(return_value=True)) +async def test_setup_with_v3_spaces_in_uri( + hass: HomeAssistant, path: str, cleaned_path: str +) -> None: + """Test migration of config entry from v3 with spaces after `socket://` URI.""" + config_entry_v3 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_RADIO_TYPE: DATA_RADIO_TYPE, + CONF_DEVICE: {CONF_DEVICE_PATH: path, CONF_BAUDRATE: 115200}, + }, + version=3, + ) + config_entry_v3.add_to_hass(hass) + + with patch( + "homeassistant.components.zha.ZHAGateway", return_value=AsyncMock() + ) as mock_gateway: + mock_gateway.return_value.coordinator_ieee = "mock_ieee" + mock_gateway.return_value.radio_description = "mock_radio" + + assert await async_setup_entry(hass, config_entry_v3) + hass.data[DOMAIN]["zha_gateway"] = mock_gateway.return_value + + assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE + assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path + assert config_entry_v3.version == 3 From 008a30618c5d316e04ec80609cce327fc3af2356 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 7 Mar 2023 21:06:29 -0500 Subject: [PATCH 0297/1058] Fix Insteon open issues with adding devices by address and missing events (#89305) * Add missing events * Bump dependancies * Update for code review --- .../components/insteon/manifest.json | 4 +- homeassistant/components/insteon/utils.py | 57 ++++++++++--------- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 40316a6ba3ec..743e7e4fa19d 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,8 +17,8 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.3.3", - "insteon-frontend-home-assistant==0.3.2" + "pyinsteon==1.3.4", + "insteon-frontend-home-assistant==0.3.3" ], "usb": [ { diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index c5dbba9c25b3..0df823e49b1c 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -1,11 +1,13 @@ """Utilities used by insteon component.""" import asyncio +from collections.abc import Callable import logging from pyinsteon import devices from pyinsteon.address import Address from pyinsteon.constants import ALDBStatus, DeviceAction -from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT +from pyinsteon.device_types.device_base import Device +from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT, Event from pyinsteon.managers.link_manager import ( async_enter_linking_mode, async_enter_unlinking_mode, @@ -27,7 +29,7 @@ from homeassistant.const import ( CONF_PLATFORM, ENTITY_MATCH_ALL, ) -from homeassistant.core import ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -89,49 +91,52 @@ from .schemas import ( _LOGGER = logging.getLogger(__name__) -def add_on_off_event_device(hass, device): +def _register_event(event: Event, listener: Callable) -> None: + """Register the events raised by a device.""" + _LOGGER.debug( + "Registering on/off event for %s %d %s", + str(event.address), + event.group, + event.name, + ) + event.subscribe(listener, force_strong_ref=True) + + +def add_on_off_event_device(hass: HomeAssistant, device: Device) -> None: """Register an Insteon device as an on/off event device.""" @callback - def async_fire_group_on_off_event(name, address, group, button): + def async_fire_group_on_off_event( + name: str, address: Address, group: int, button: str + ): # Firing an event when a button is pressed. if button and button[-2] == "_": button_id = button[-1].lower() else: button_id = None - schema = {CONF_ADDRESS: address} + schema = {CONF_ADDRESS: address, "group": group} if button_id: schema[EVENT_CONF_BUTTON] = button_id if name == ON_EVENT: event = EVENT_GROUP_ON - if name == OFF_EVENT: + elif name == OFF_EVENT: event = EVENT_GROUP_OFF - if name == ON_FAST_EVENT: + elif name == ON_FAST_EVENT: event = EVENT_GROUP_ON_FAST - if name == OFF_FAST_EVENT: + elif name == OFF_FAST_EVENT: event = EVENT_GROUP_OFF_FAST + else: + event = f"insteon.{name}" _LOGGER.debug("Firing event %s with %s", event, schema) hass.bus.async_fire(event, schema) - for group in device.events: - if isinstance(group, int): - for event in device.events[group]: - if event in [ - OFF_EVENT, - ON_EVENT, - OFF_FAST_EVENT, - ON_FAST_EVENT, - ]: - _LOGGER.debug( - "Registering on/off event for %s %d %s", - str(device.address), - group, - event, - ) - device.events[group][event].subscribe( - async_fire_group_on_off_event, force_strong_ref=True - ) + for name_or_group, event in device.events.items(): + if isinstance(name_or_group, int): + for _, event in device.events[name_or_group].items(): + _register_event(event, async_fire_group_on_off_event) + else: + _register_event(event, async_fire_group_on_off_event) def register_new_device_callback(hass): diff --git a/requirements_all.txt b/requirements_all.txt index 0ca58159d054..055e8881ec15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -979,7 +979,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.2 +insteon-frontend-home-assistant==0.3.3 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1687,7 +1687,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.3 +pyinsteon==1.3.4 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d86bf8a0a2f..e5486a45819a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -738,7 +738,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.2 +insteon-frontend-home-assistant==0.3.3 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1212,7 +1212,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.3 +pyinsteon==1.3.4 # homeassistant.components.ipma pyipma==3.0.6 From 3e5e937541dd368f3d70c7557ceaa7a07fd284e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Mar 2023 16:07:24 -1000 Subject: [PATCH 0298/1058] Use a filter for the PersonStorageCollection EVENT_ENTITY_REGISTRY_UPDATED listener (#89335) Avoids creating a task unless a device_tracker is removed --- homeassistant/components/person/__init__.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index e3b719d166fc..a5e56d007312 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -226,19 +226,22 @@ class PersonStorageCollection(collection.StorageCollection): """Load the Storage collection.""" await super().async_load() self.hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, self._entity_registry_updated + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._entity_registry_updated, + event_filter=self._entity_registry_filter, ) - async def _entity_registry_updated(self, event) -> None: + @callback + def _entity_registry_filter(self, event: Event) -> bool: + """Filter entity registry events.""" + return ( + event.data["action"] == "remove" + and split_entity_id(event.data[ATTR_ENTITY_ID])[0] == "device_tracker" + ) + + async def _entity_registry_updated(self, event: Event) -> None: """Handle entity registry updated.""" - if event.data["action"] != "remove": - return - entity_id = event.data[ATTR_ENTITY_ID] - - if split_entity_id(entity_id)[0] != "device_tracker": - return - for person in list(self.data.values()): if entity_id not in person[CONF_DEVICE_TRACKERS]: continue From ff83b8adb8dfecb361e630aabdd50c35e52d1207 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 8 Mar 2023 00:26:34 -0700 Subject: [PATCH 0299/1058] Bump pybalboa to 1.0.1 (#89310) --- homeassistant/components/balboa/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index b81c681f829b..152a89bde315 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], - "requirements": ["pybalboa==1.0.0"] + "requirements": ["pybalboa==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 055e8881ec15..78badcd39fa8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1516,7 +1516,7 @@ pyatv==0.10.3 pyaussiebb==0.0.15 # homeassistant.components.balboa -pybalboa==1.0.0 +pybalboa==1.0.1 # homeassistant.components.bbox pybbox==0.0.5-alpha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5486a45819a..7176b1041897 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ pyatv==0.10.3 pyaussiebb==0.0.15 # homeassistant.components.balboa -pybalboa==1.0.0 +pybalboa==1.0.1 # homeassistant.components.blackbird pyblackbird==0.5 From 58280dc2ec056f9f7cfea5d3c98053fde9b2fe0b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Mar 2023 08:39:15 +0100 Subject: [PATCH 0300/1058] Improve gios generic typing (#89321) --- homeassistant/components/gios/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 4aad3b053709..213fabc911bf 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -2,13 +2,13 @@ from __future__ import annotations import logging -from typing import Any, cast from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout from gios import Gios from gios.exceptions import GiosError +from gios.model import GiosSensors from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -74,7 +74,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class GiosDataUpdateCoordinator(DataUpdateCoordinator): +class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): """Define an object to hold GIOS data.""" def __init__( @@ -85,10 +85,10 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> GiosSensors: """Update data via library.""" try: async with timeout(API_TIMEOUT): - return cast(dict[str, Any], await self.gios.async_update()) + return await self.gios.async_update() except (GiosError, ClientConnectorError) as error: raise UpdateFailed(error) from error From adb4414440632613dd614a0fd3fc0bee7071747a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 08:43:07 +0100 Subject: [PATCH 0301/1058] Add missing mock in brother config flow tests (#89354) --- tests/components/brother/conftest.py | 14 ++++++++++++++ tests/components/brother/test_config_flow.py | 3 +++ 2 files changed, 17 insertions(+) create mode 100644 tests/components/brother/conftest.py diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py new file mode 100644 index 000000000000..9e81cce9d123 --- /dev/null +++ b/tests/components/brother/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for brother.""" +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.brother.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 9dc871bb4cad..0e1db72ddae5 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -3,6 +3,7 @@ import json from unittest.mock import patch from brother import SnmpError, UnsupportedModelError +import pytest from homeassistant import data_entry_flow from homeassistant.components import zeroconf @@ -15,6 +16,8 @@ from tests.common import MockConfigEntry, load_fixture CONFIG = {CONF_HOST: "127.0.0.1", CONF_TYPE: "laser"} +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" From 30884f6d178ff4b86432325e2a9e76638db82ff6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 09:38:55 +0100 Subject: [PATCH 0302/1058] Add missing mock in axis config flow tests (#89365) --- tests/components/axis/conftest.py | 13 ++++++++++++- tests/components/axis/test_config_flow.py | 19 +++++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 6be1793cc246..5c9c4e5a2559 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -1,8 +1,9 @@ """Axis conftest.""" from __future__ import annotations +from collections.abc import Generator from copy import deepcopy -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from axis.rtsp import Signal, State import pytest @@ -41,6 +42,16 @@ from .const import ( from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.axis.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + # Config entry fixtures diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index 2f2b4b52fb4d..2c5b3a505134 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -38,16 +38,15 @@ from tests.common import MockConfigEntry @pytest.fixture(name="mock_config_entry") -async def mock_config_entry_fixture(hass, config_entry): +async def mock_config_entry_fixture(hass, config_entry, mock_setup_entry): """Mock config entry and setup entry.""" - with patch("homeassistant.components.axis.async_setup_entry", return_value=True): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - yield config_entry + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return config_entry async def test_flow_manual_configuration( - hass: HomeAssistant, setup_default_vapix_requests + hass: HomeAssistant, setup_default_vapix_requests, mock_setup_entry ) -> None: """Test that config flow works.""" MockConfigEntry(domain=AXIS_DOMAIN, source=SOURCE_IGNORE).add_to_hass(hass) @@ -164,7 +163,7 @@ async def test_flow_fails_cannot_connect(hass: HomeAssistant) -> None: async def test_flow_create_entry_multiple_existing_entries_of_same_model( - hass: HomeAssistant, setup_default_vapix_requests + hass: HomeAssistant, setup_default_vapix_requests, mock_setup_entry ) -> None: """Test that create entry can generate a name with other entries.""" entry = MockConfigEntry( @@ -310,7 +309,11 @@ async def test_reauth_flow_update_configuration( ], ) async def test_discovery_flow( - hass: HomeAssistant, setup_default_vapix_requests, source: str, discovery_info: dict + hass: HomeAssistant, + setup_default_vapix_requests, + source: str, + discovery_info: dict, + mock_setup_entry, ) -> None: """Test the different discovery flows for new devices work.""" result = await hass.config_entries.flow.async_init( From 452e1d341de76c27ebaf04eb3039d5bd05de2988 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 8 Mar 2023 09:00:40 +0000 Subject: [PATCH 0303/1058] Remove invalid device class in air-Q integration (#89329) Remove device_class from sensors using inconsistent units --- homeassistant/components/airq/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index e46893e8d791..a47c308279d8 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -68,7 +68,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="co", name="CO", - device_class=SensorDeviceClass.CO, native_unit_of_measurement=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co"), @@ -289,7 +288,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="tvoc", name="VOC", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc"), @@ -297,7 +295,6 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ AirQEntityDescription( key="tvoc_ionsc", name="VOC (Industrial)", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), From 9381865f1ce7e78a1d4a3fdf56d11e542de5f24f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Mar 2023 12:25:51 +0100 Subject: [PATCH 0304/1058] Fix setting Reolink focus (#89374) fix setting focus --- homeassistant/components/reolink/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 7c50bfa9f071..616fc8b74c16 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -65,7 +65,7 @@ NUMBER_ENTITIES = ( get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"], supported=lambda api, ch: api.supported(ch, "zoom"), value=lambda api, ch: api.get_focus(ch), - method=lambda api, ch, value: api.set_zoom(ch, int(value)), + method=lambda api, ch, value: api.set_focus(ch, int(value)), ), # "Floodlight turn on brightness" controls the brightness of the floodlight when # it is turned on internally by the camera (see "select.floodlight_mode" entity) From feb3f543bec3f190bd152beea99d09aa5b168ad7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Mar 2023 15:21:11 +0100 Subject: [PATCH 0305/1058] Improve Supervisor API handling (#89379) --- homeassistant/components/hassio/const.py | 1 + homeassistant/components/hassio/handler.py | 7 +- homeassistant/components/hassio/http.py | 159 ++++--- homeassistant/components/hassio/ingress.py | 22 +- .../components/hassio/websocket_api.py | 1 + tests/components/hassio/conftest.py | 28 +- tests/components/hassio/test_handler.py | 103 ++++- tests/components/hassio/test_http.py | 435 ++++++++++++++---- tests/components/hassio/test_ingress.py | 71 ++- tests/components/hassio/test_websocket_api.py | 5 + 10 files changed, 620 insertions(+), 212 deletions(-) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 64ef7a718a5c..2710e146540d 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -36,6 +36,7 @@ X_AUTH_TOKEN = "X-Supervisor-Token" X_INGRESS_PATH = "X-Ingress-Path" X_HASS_USER_ID = "X-Hass-User-ID" X_HASS_IS_ADMIN = "X-Hass-Is-Admin" +X_HASS_SOURCE = "X-Hass-Source" WS_TYPE = "type" WS_ID = "id" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 0d923075bf75..762df4f79ca1 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -17,7 +17,7 @@ from homeassistant.const import SERVER_PORT from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass -from .const import ATTR_DISCOVERY, DOMAIN +from .const import ATTR_DISCOVERY, DOMAIN, X_HASS_SOURCE _LOGGER = logging.getLogger(__name__) @@ -445,6 +445,8 @@ class HassIO: payload=None, timeout=10, return_text=False, + *, + source="core.handler", ): """Send API command to Hass.io. @@ -458,7 +460,8 @@ class HassIO: headers={ aiohttp.hdrs.AUTHORIZATION: ( f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}" - ) + ), + X_HASS_SOURCE: source, }, timeout=aiohttp.ClientTimeout(total=timeout), ) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 2b7145bdcaaa..8a8583a7dafb 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -6,6 +6,7 @@ from http import HTTPStatus import logging import os import re +from urllib.parse import quote, unquote import aiohttp from aiohttp import web @@ -19,13 +20,16 @@ from aiohttp.hdrs import ( TRANSFER_ENCODING, ) from aiohttp.web_exceptions import HTTPBadGateway -from multidict import istr -from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView +from homeassistant.components.http import ( + KEY_AUTHENTICATED, + KEY_HASS_USER, + HomeAssistantView, +) from homeassistant.components.onboarding import async_is_onboarded from homeassistant.core import HomeAssistant -from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID +from .const import X_HASS_SOURCE _LOGGER = logging.getLogger(__name__) @@ -34,23 +38,53 @@ MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 # pylint: disable=implicit-str-concat NO_TIMEOUT = re.compile( r"^(?:" - r"|homeassistant/update" - r"|hassos/update" - r"|hassos/update/cli" - r"|supervisor/update" - r"|addons/[^/]+/(?:update|install|rebuild)" r"|backups/.+/full" r"|backups/.+/partial" r"|backups/[^/]+/(?:upload|download)" r")$" ) -NO_AUTH_ONBOARDING = re.compile(r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r")$") +# fmt: off +# Onboarding can upload backups and restore it +PATHS_NOT_ONBOARDED = re.compile( + r"^(?:" + r"|backups/[a-f0-9]{8}(/info|/new/upload|/download|/restore/full|/restore/partial)?" + r"|backups/new/upload" + r")$" +) -NO_AUTH = re.compile(r"^(?:" r"|app/.*" r"|[store\/]*addons/[^/]+/(logo|icon)" r")$") +# Authenticated users manage backups + download logs +PATHS_ADMIN = re.compile( + r"^(?:" + r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?" + r"|backups/new/upload" + r"|audio/logs" + r"|cli/logs" + r"|core/logs" + r"|dns/logs" + r"|host/logs" + r"|multicast/logs" + r"|observer/logs" + r"|supervisor/logs" + r"|addons/[^/]+/logs" + r")$" +) -NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") +# Unauthenticated requests come in for Supervisor panel + add-on images +PATHS_NO_AUTH = re.compile( + r"^(?:" + r"|app/.*" + r"|(store/)?addons/[^/]+/(logo|icon)" + r")$" +) + +NO_STORE = re.compile( + r"^(?:" + r"|app/entrypoint.js" + r")$" +) # pylint: enable=implicit-str-concat +# fmt: on class HassIOView(HomeAssistantView): @@ -65,38 +99,66 @@ class HassIOView(HomeAssistantView): self._host = host self._websession = websession - async def _handle( - self, request: web.Request, path: str - ) -> web.Response | web.StreamResponse: - """Route data to Hass.io.""" - hass = request.app["hass"] - if _need_auth(hass, path) and not request[KEY_AUTHENTICATED]: - return web.Response(status=HTTPStatus.UNAUTHORIZED) - - return await self._command_proxy(path, request) - - delete = _handle - get = _handle - post = _handle - - async def _command_proxy( - self, path: str, request: web.Request - ) -> web.StreamResponse: + async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: """Return a client request with proxy origin for Hass.io supervisor. - This method is a coroutine. + Use cases: + - Onboarding allows restoring backups + - Load Supervisor panel and add-on logo unauthenticated + - User upload/restore backups """ - headers = _init_header(request) - if path == "backups/new/upload": - # We need to reuse the full content type that includes the boundary - headers[ - CONTENT_TYPE - ] = request._stored_content_type # pylint: disable=protected-access + # No bullshit + if path != unquote(path): + return web.Response(status=HTTPStatus.BAD_REQUEST) + + hass: HomeAssistant = request.app["hass"] + is_admin = request[KEY_AUTHENTICATED] and request[KEY_HASS_USER].is_admin + authorized = is_admin + + if is_admin: + allowed_paths = PATHS_ADMIN + + elif not async_is_onboarded(hass): + allowed_paths = PATHS_NOT_ONBOARDED + + # During onboarding we need the user to manage backups + authorized = True + + else: + # Either unauthenticated or not an admin + allowed_paths = PATHS_NO_AUTH + + no_auth_path = PATHS_NO_AUTH.match(path) + headers = { + X_HASS_SOURCE: "core.http", + } + + if no_auth_path: + if request.method != "GET": + return web.Response(status=HTTPStatus.METHOD_NOT_ALLOWED) + + else: + if not allowed_paths.match(path): + return web.Response(status=HTTPStatus.UNAUTHORIZED) + + if authorized: + headers[ + AUTHORIZATION + ] = f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}" + + if request.method == "POST": + headers[CONTENT_TYPE] = request.content_type + # _stored_content_type is only computed once `content_type` is accessed + if path == "backups/new/upload": + # We need to reuse the full content type that includes the boundary + headers[ + CONTENT_TYPE + ] = request._stored_content_type # pylint: disable=protected-access try: client = await self._websession.request( method=request.method, - url=f"http://{self._host}/{path}", + url=f"http://{self._host}/{quote(path)}", params=request.query, data=request.content, headers=headers, @@ -123,20 +185,8 @@ class HassIOView(HomeAssistantView): raise HTTPBadGateway() - -def _init_header(request: web.Request) -> dict[istr, str]: - """Create initial header.""" - headers = { - AUTHORIZATION: f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}", - CONTENT_TYPE: request.content_type, - } - - # Add user data - if request.get("hass_user") is not None: - headers[istr(X_HASS_USER_ID)] = request["hass_user"].id - headers[istr(X_HASS_IS_ADMIN)] = str(int(request["hass_user"].is_admin)) - - return headers + get = _handle + post = _handle def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: @@ -164,12 +214,3 @@ def _get_timeout(path: str) -> ClientTimeout: if NO_TIMEOUT.match(path): return ClientTimeout(connect=10, total=None) return ClientTimeout(connect=10, total=300) - - -def _need_auth(hass: HomeAssistant, path: str) -> bool: - """Return if a path need authentication.""" - if not async_is_onboarded(hass) and NO_AUTH_ONBOARDING.match(path): - return False - if NO_AUTH.match(path): - return False - return True diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index dceff75bca82..334c7cf719cc 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -3,20 +3,22 @@ from __future__ import annotations import asyncio from collections.abc import Iterable +from functools import lru_cache from ipaddress import ip_address import logging -import os +from urllib.parse import quote import aiohttp from aiohttp import ClientTimeout, hdrs, web from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict +from yarl import URL from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import X_AUTH_TOKEN, X_INGRESS_PATH +from .const import X_HASS_SOURCE, X_INGRESS_PATH _LOGGER = logging.getLogger(__name__) @@ -42,9 +44,19 @@ class HassIOIngress(HomeAssistantView): self._host = host self._websession = websession + @lru_cache def _create_url(self, token: str, path: str) -> str: """Create URL to service.""" - return f"http://{self._host}/ingress/{token}/{path}" + base_path = f"/ingress/{token}/" + url = f"http://{self._host}{base_path}{quote(path)}" + + try: + if not URL(url).path.startswith(base_path): + raise HTTPBadRequest() + except ValueError as err: + raise HTTPBadRequest() from err + + return url async def _handle( self, request: web.Request, token: str, path: str @@ -185,10 +197,8 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st continue headers[name] = value - # Inject token / cleanup later on Supervisor - headers[X_AUTH_TOKEN] = os.environ.get("SUPERVISOR_TOKEN", "") - # Ingress information + headers[X_HASS_SOURCE] = "core.ingress" headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" # Set X-Forwarded-For diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 3670d5ca1fd9..8a9a145f2d6f 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -116,6 +116,7 @@ async def websocket_supervisor_api( method=msg[ATTR_METHOD], timeout=msg.get(ATTR_TIMEOUT, 10), payload=msg.get(ATTR_DATA, {}), + source="core.websocket_api", ) if result.get(ATTR_RESULT) == "error": diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a6cd956c95e4..78ae9643d68b 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -1,5 +1,6 @@ """Fixtures for Hass.io.""" import os +import re from unittest.mock import Mock, patch import pytest @@ -12,6 +13,16 @@ from homeassistant.setup import async_setup_component from . import SUPERVISOR_TOKEN +@pytest.fixture(autouse=True) +def disable_security_filter(): + """Disable the security filter to ensure the integration is secure.""" + with patch( + "homeassistant.components.http.security_filter.FILTERS", + re.compile("not-matching-anything"), + ): + yield + + @pytest.fixture def hassio_env(): """Fixture to inject hassio env.""" @@ -37,6 +48,13 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): ), patch( "homeassistant.components.hassio.HassIO.get_info", side_effect=HassioAPIError(), + ), patch( + "homeassistant.components.hassio.HassIO.get_ingress_panels", + return_value={"panels": []}, + ), patch( + "homeassistant.components.hassio.repairs.SupervisorRepairs.setup" + ), patch( + "homeassistant.components.hassio.HassIO.refresh_updates" ): hass.state = CoreState.starting hass.loop.run_until_complete(async_setup_component(hass, "hassio", {})) @@ -67,13 +85,7 @@ async def hassio_client_supervisor(hass, aiohttp_client, hassio_stubs): @pytest.fixture -def hassio_handler(hass, aioclient_mock): +async def hassio_handler(hass, aioclient_mock): """Create mock hassio handler.""" - - async def get_client_session(): - return async_get_clientsession(hass) - - websession = hass.loop.run_until_complete(get_client_session()) - with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}): - yield HassIO(hass.loop, websession, "127.0.0.1") + yield HassIO(hass.loop, async_get_clientsession(hass), "127.0.0.1") diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index ee23d5d350e0..64e9e1c31cc5 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -1,13 +1,21 @@ """The tests for the hassio component.""" +from __future__ import annotations + +from typing import Any, Literal + import aiohttp +from aiohttp import hdrs, web import pytest -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio.handler import HassIO, HassioAPIError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from tests.test_util.aiohttp import AiohttpClientMocker -async def test_api_ping(hassio_handler, aioclient_mock: AiohttpClientMocker) -> None: +async def test_api_ping( + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker +) -> None: """Test setup with API ping.""" aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) @@ -16,7 +24,7 @@ async def test_api_ping(hassio_handler, aioclient_mock: AiohttpClientMocker) -> async def test_api_ping_error( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API ping error.""" aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "error"}) @@ -26,7 +34,7 @@ async def test_api_ping_error( async def test_api_ping_exeption( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API ping exception.""" aioclient_mock.get("http://127.0.0.1/supervisor/ping", exc=aiohttp.ClientError()) @@ -35,7 +43,9 @@ async def test_api_ping_exeption( assert aioclient_mock.call_count == 1 -async def test_api_info(hassio_handler, aioclient_mock: AiohttpClientMocker) -> None: +async def test_api_info( + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker +) -> None: """Test setup with API generic info.""" aioclient_mock.get( "http://127.0.0.1/info", @@ -53,7 +63,7 @@ async def test_api_info(hassio_handler, aioclient_mock: AiohttpClientMocker) -> async def test_api_info_error( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Home Assistant info error.""" aioclient_mock.get( @@ -67,7 +77,7 @@ async def test_api_info_error( async def test_api_host_info( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Host info.""" aioclient_mock.get( @@ -90,7 +100,7 @@ async def test_api_host_info( async def test_api_supervisor_info( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Supervisor info.""" aioclient_mock.get( @@ -108,7 +118,9 @@ async def test_api_supervisor_info( assert data["channel"] == "stable" -async def test_api_os_info(hassio_handler, aioclient_mock: AiohttpClientMocker) -> None: +async def test_api_os_info( + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker +) -> None: """Test setup with API OS info.""" aioclient_mock.get( "http://127.0.0.1/os/info", @@ -125,7 +137,7 @@ async def test_api_os_info(hassio_handler, aioclient_mock: AiohttpClientMocker) async def test_api_host_info_error( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Home Assistant info error.""" aioclient_mock.get( @@ -139,7 +151,7 @@ async def test_api_host_info_error( async def test_api_core_info( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Home Assistant Core info.""" aioclient_mock.get( @@ -153,7 +165,7 @@ async def test_api_core_info( async def test_api_core_info_error( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Home Assistant Core info error.""" aioclient_mock.get( @@ -167,7 +179,7 @@ async def test_api_core_info_error( async def test_api_homeassistant_stop( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Home Assistant stop.""" aioclient_mock.post("http://127.0.0.1/homeassistant/stop", json={"result": "ok"}) @@ -177,7 +189,7 @@ async def test_api_homeassistant_stop( async def test_api_homeassistant_restart( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Home Assistant restart.""" aioclient_mock.post("http://127.0.0.1/homeassistant/restart", json={"result": "ok"}) @@ -187,7 +199,7 @@ async def test_api_homeassistant_restart( async def test_api_addon_info( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Add-on info.""" aioclient_mock.get( @@ -201,7 +213,7 @@ async def test_api_addon_info( async def test_api_addon_stats( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Add-on stats.""" aioclient_mock.get( @@ -215,7 +227,7 @@ async def test_api_addon_stats( async def test_api_discovery_message( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API discovery message.""" aioclient_mock.get( @@ -229,7 +241,7 @@ async def test_api_discovery_message( async def test_api_retrieve_discovery( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API discovery message.""" aioclient_mock.get( @@ -243,7 +255,7 @@ async def test_api_retrieve_discovery( async def test_api_ingress_panels( - hassio_handler, aioclient_mock: AiohttpClientMocker + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: """Test setup with API Ingress panels.""" aioclient_mock.get( @@ -267,3 +279,56 @@ async def test_api_ingress_panels( assert aioclient_mock.call_count == 1 assert data["panels"] assert "slug" in data["panels"] + + +@pytest.mark.parametrize( + ("api_call", "method", "payload"), + [ + ["retrieve_discovery_messages", "GET", None], + ["refresh_updates", "POST", None], + ["update_diagnostics", "POST", True], + ], +) +async def test_api_headers( + hass, + aiohttp_raw_server, + socket_enabled, + api_call: str, + method: Literal["GET", "POST"], + payload: Any, +) -> None: + """Test headers are forwarded correctly.""" + received_request = None + + async def mock_handler(request): + """Return OK.""" + nonlocal received_request + received_request = request + return web.json_response({"result": "ok", "data": None}) + + server = await aiohttp_raw_server(mock_handler) + hassio_handler = HassIO( + hass.loop, + async_get_clientsession(hass), + f"{server.host}:{server.port}", + ) + + api_func = getattr(hassio_handler, api_call) + if payload: + await api_func(payload) + else: + await api_func() + assert received_request is not None + + assert received_request.method == method + assert received_request.headers.get("X-Hass-Source") == "core.handler" + + if method == "GET": + assert hdrs.CONTENT_TYPE not in received_request.headers + return + + assert hdrs.CONTENT_TYPE in received_request.headers + if payload: + assert received_request.headers[hdrs.CONTENT_TYPE] == "application/json" + else: + assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream" diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index 8ef6fa4001bb..cb1dd639ec62 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -1,63 +1,45 @@ """The tests for the hassio component.""" import asyncio from http import HTTPStatus +from unittest.mock import patch from aiohttp import StreamReader import pytest -from homeassistant.components.hassio.http import _need_auth -from homeassistant.core import HomeAssistant - -from tests.common import MockUser from tests.test_util.aiohttp import AiohttpClientMocker -async def test_forward_request( - hassio_client, aioclient_mock: AiohttpClientMocker -) -> None: - """Test fetching normal path.""" - aioclient_mock.post("http://127.0.0.1/beer", text="response") +@pytest.fixture +def mock_not_onboarded(): + """Mock that we're not onboarded.""" + with patch( + "homeassistant.components.hassio.http.async_is_onboarded", return_value=False + ): + yield - resp = await hassio_client.post("/api/hassio/beer") - # Check we got right response - assert resp.status == HTTPStatus.OK - body = await resp.text() - assert body == "response" - - # Check we forwarded command - assert len(aioclient_mock.mock_calls) == 1 +@pytest.fixture +def hassio_user_client(hassio_client, hass_admin_user): + """Return a Hass.io HTTP client tied to a non-admin user.""" + hass_admin_user.groups = [] + return hassio_client @pytest.mark.parametrize( - "build_type", ["supervisor/info", "homeassistant/update", "host/info"] -) -async def test_auth_required_forward_request(hassio_noauth_client, build_type) -> None: - """Test auth required for normal request.""" - resp = await hassio_noauth_client.post(f"/api/hassio/{build_type}") - - # Check we got right response - assert resp.status == HTTPStatus.UNAUTHORIZED - - -@pytest.mark.parametrize( - "build_type", + "path", [ - "app/index.html", - "app/hassio-app.html", - "app/index.html", - "app/hassio-app.html", - "app/some-chunk.js", - "app/app.js", + "app/entrypoint.js", + "addons/bl_b392/logo", + "addons/bl_b392/icon", ], ) -async def test_forward_request_no_auth_for_panel( - hassio_client, build_type, aioclient_mock: AiohttpClientMocker +async def test_forward_request_onboarded_user_get( + hassio_user_client, aioclient_mock: AiohttpClientMocker, path: str ) -> None: - """Test no auth needed for .""" - aioclient_mock.get(f"http://127.0.0.1/{build_type}", text="response") + """Test fetching normal path.""" + aioclient_mock.get(f"http://127.0.0.1/{path}", text="response") - resp = await hassio_client.get(f"/api/hassio/{build_type}") + resp = await hassio_user_client.get(f"/api/hassio/{path}") # Check we got right response assert resp.status == HTTPStatus.OK @@ -66,15 +48,68 @@ async def test_forward_request_no_auth_for_panel( # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 + # We only expect a single header. + assert aioclient_mock.mock_calls[0][3] == {"X-Hass-Source": "core.http"} -async def test_forward_request_no_auth_for_logo( - hassio_client, aioclient_mock: AiohttpClientMocker +@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "RANDOM"]) +async def test_forward_request_onboarded_user_unallowed_methods( + hassio_user_client, aioclient_mock: AiohttpClientMocker, method: str ) -> None: - """Test no auth needed for logo.""" - aioclient_mock.get("http://127.0.0.1/addons/bl_b392/logo", text="response") + """Test fetching normal path.""" + resp = await hassio_user_client.post("/api/hassio/app/entrypoint.js") - resp = await hassio_client.get("/api/hassio/addons/bl_b392/logo") + # Check we got right response + assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED + + # Check we did not forward command + assert len(aioclient_mock.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("bad_path", "expected_status"), + [ + # Caught by bullshit filter + ("app/%252E./entrypoint.js", HTTPStatus.BAD_REQUEST), + # The .. is processed, making it an unauthenticated path + ("app/../entrypoint.js", HTTPStatus.UNAUTHORIZED), + ("app/%2E%2E/entrypoint.js", HTTPStatus.UNAUTHORIZED), + # Unauthenticated path + ("supervisor/info", HTTPStatus.UNAUTHORIZED), + ("supervisor/logs", HTTPStatus.UNAUTHORIZED), + ("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED), + ], +) +async def test_forward_request_onboarded_user_unallowed_paths( + hassio_user_client, + aioclient_mock: AiohttpClientMocker, + bad_path: str, + expected_status: int, +) -> None: + """Test fetching normal path.""" + resp = await hassio_user_client.get(f"/api/hassio/{bad_path}") + + # Check we got right response + assert resp.status == expected_status + # Check we didn't forward command + assert len(aioclient_mock.mock_calls) == 0 + + +@pytest.mark.parametrize( + "path", + [ + "app/entrypoint.js", + "addons/bl_b392/logo", + "addons/bl_b392/icon", + ], +) +async def test_forward_request_onboarded_noauth_get( + hassio_noauth_client, aioclient_mock: AiohttpClientMocker, path: str +) -> None: + """Test fetching normal path.""" + aioclient_mock.get(f"http://127.0.0.1/{path}", text="response") + + resp = await hassio_noauth_client.get(f"/api/hassio/{path}") # Check we got right response assert resp.status == HTTPStatus.OK @@ -83,15 +118,73 @@ async def test_forward_request_no_auth_for_logo( # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 + # We only expect a single header. + assert aioclient_mock.mock_calls[0][3] == {"X-Hass-Source": "core.http"} -async def test_forward_request_no_auth_for_icon( - hassio_client, aioclient_mock: AiohttpClientMocker +@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "RANDOM"]) +async def test_forward_request_onboarded_noauth_unallowed_methods( + hassio_noauth_client, aioclient_mock: AiohttpClientMocker, method: str ) -> None: - """Test no auth needed for icon.""" - aioclient_mock.get("http://127.0.0.1/addons/bl_b392/icon", text="response") + """Test fetching normal path.""" + resp = await hassio_noauth_client.post("/api/hassio/app/entrypoint.js") - resp = await hassio_client.get("/api/hassio/addons/bl_b392/icon") + # Check we got right response + assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED + + # Check we did not forward command + assert len(aioclient_mock.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("bad_path", "expected_status"), + [ + # Caught by bullshit filter + ("app/%252E./entrypoint.js", HTTPStatus.BAD_REQUEST), + # The .. is processed, making it an unauthenticated path + ("app/../entrypoint.js", HTTPStatus.UNAUTHORIZED), + ("app/%2E%2E/entrypoint.js", HTTPStatus.UNAUTHORIZED), + # Unauthenticated path + ("supervisor/info", HTTPStatus.UNAUTHORIZED), + ("supervisor/logs", HTTPStatus.UNAUTHORIZED), + ("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED), + ], +) +async def test_forward_request_onboarded_noauth_unallowed_paths( + hassio_noauth_client, + aioclient_mock: AiohttpClientMocker, + bad_path: str, + expected_status: int, +) -> None: + """Test fetching normal path.""" + resp = await hassio_noauth_client.get(f"/api/hassio/{bad_path}") + + # Check we got right response + assert resp.status == expected_status + # Check we didn't forward command + assert len(aioclient_mock.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("path", "authenticated"), + [ + ("app/entrypoint.js", False), + ("addons/bl_b392/logo", False), + ("addons/bl_b392/icon", False), + ("backups/1234abcd/info", True), + ], +) +async def test_forward_request_not_onboarded_get( + hassio_noauth_client, + aioclient_mock: AiohttpClientMocker, + path: str, + authenticated: bool, + mock_not_onboarded, +) -> None: + """Test fetching normal path.""" + aioclient_mock.get(f"http://127.0.0.1/{path}", text="response") + + resp = await hassio_noauth_client.get(f"/api/hassio/{path}") # Check we got right response assert resp.status == HTTPStatus.OK @@ -100,61 +193,224 @@ async def test_forward_request_no_auth_for_icon( # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 + expected_headers = { + "X-Hass-Source": "core.http", + } + if authenticated: + expected_headers["Authorization"] = "Bearer 123456" + + assert aioclient_mock.mock_calls[0][3] == expected_headers -async def test_forward_log_request( - hassio_client, aioclient_mock: AiohttpClientMocker +@pytest.mark.parametrize( + "path", + [ + "backups/new/upload", + "backups/1234abcd/restore/full", + "backups/1234abcd/restore/partial", + ], +) +async def test_forward_request_not_onboarded_post( + hassio_noauth_client, + aioclient_mock: AiohttpClientMocker, + path: str, + mock_not_onboarded, ) -> None: - """Test fetching normal log path doesn't remove ANSI color escape codes.""" - aioclient_mock.get("http://127.0.0.1/beer/logs", text="\033[32mresponse\033[0m") + """Test fetching normal path.""" + aioclient_mock.get(f"http://127.0.0.1/{path}", text="response") - resp = await hassio_client.get("/api/hassio/beer/logs") + resp = await hassio_noauth_client.get(f"/api/hassio/{path}") # Check we got right response assert resp.status == HTTPStatus.OK body = await resp.text() - assert body == "\033[32mresponse\033[0m" + assert body == "response" # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 + # We only expect a single header. + assert aioclient_mock.mock_calls[0][3] == { + "X-Hass-Source": "core.http", + "Authorization": "Bearer 123456", + } + + +@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "RANDOM"]) +async def test_forward_request_not_onboarded_unallowed_methods( + hassio_noauth_client, aioclient_mock: AiohttpClientMocker, method: str +) -> None: + """Test fetching normal path.""" + resp = await hassio_noauth_client.post("/api/hassio/app/entrypoint.js") + + # Check we got right response + assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED + + # Check we did not forward command + assert len(aioclient_mock.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("bad_path", "expected_status"), + [ + # Caught by bullshit filter + ("app/%252E./entrypoint.js", HTTPStatus.BAD_REQUEST), + # The .. is processed, making it an unauthenticated path + ("app/../entrypoint.js", HTTPStatus.UNAUTHORIZED), + ("app/%2E%2E/entrypoint.js", HTTPStatus.UNAUTHORIZED), + # Unauthenticated path + ("supervisor/info", HTTPStatus.UNAUTHORIZED), + ("supervisor/logs", HTTPStatus.UNAUTHORIZED), + ("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED), + ], +) +async def test_forward_request_not_onboarded_unallowed_paths( + hassio_noauth_client, + aioclient_mock: AiohttpClientMocker, + bad_path: str, + expected_status: int, + mock_not_onboarded, +) -> None: + """Test fetching normal path.""" + resp = await hassio_noauth_client.get(f"/api/hassio/{bad_path}") + + # Check we got right response + assert resp.status == expected_status + # Check we didn't forward command + assert len(aioclient_mock.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("path", "authenticated"), + [ + ("app/entrypoint.js", False), + ("addons/bl_b392/logo", False), + ("addons/bl_b392/icon", False), + ("backups/1234abcd/info", True), + ("supervisor/logs", True), + ("addons/bl_b392/logs", True), + ], +) +async def test_forward_request_admin_get( + hassio_client, + aioclient_mock: AiohttpClientMocker, + path: str, + authenticated: bool, +) -> None: + """Test fetching normal path.""" + aioclient_mock.get(f"http://127.0.0.1/{path}", text="response") + + resp = await hassio_client.get(f"/api/hassio/{path}") + + # Check we got right response + assert resp.status == HTTPStatus.OK + body = await resp.text() + assert body == "response" + + # Check we forwarded command + assert len(aioclient_mock.mock_calls) == 1 + expected_headers = { + "X-Hass-Source": "core.http", + } + if authenticated: + expected_headers["Authorization"] = "Bearer 123456" + + assert aioclient_mock.mock_calls[0][3] == expected_headers + + +@pytest.mark.parametrize( + "path", + [ + "backups/new/upload", + "backups/1234abcd/restore/full", + "backups/1234abcd/restore/partial", + ], +) +async def test_forward_request_admin_post( + hassio_client, + aioclient_mock: AiohttpClientMocker, + path: str, +) -> None: + """Test fetching normal path.""" + aioclient_mock.get(f"http://127.0.0.1/{path}", text="response") + + resp = await hassio_client.get(f"/api/hassio/{path}") + + # Check we got right response + assert resp.status == HTTPStatus.OK + body = await resp.text() + assert body == "response" + + # Check we forwarded command + assert len(aioclient_mock.mock_calls) == 1 + # We only expect a single header. + assert aioclient_mock.mock_calls[0][3] == { + "X-Hass-Source": "core.http", + "Authorization": "Bearer 123456", + } + + +@pytest.mark.parametrize("method", ["POST", "PUT", "DELETE", "RANDOM"]) +async def test_forward_request_admin_unallowed_methods( + hassio_client, aioclient_mock: AiohttpClientMocker, method: str +) -> None: + """Test fetching normal path.""" + resp = await hassio_client.post("/api/hassio/app/entrypoint.js") + + # Check we got right response + assert resp.status == HTTPStatus.METHOD_NOT_ALLOWED + + # Check we did not forward command + assert len(aioclient_mock.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("bad_path", "expected_status"), + [ + # Caught by bullshit filter + ("app/%252E./entrypoint.js", HTTPStatus.BAD_REQUEST), + # The .. is processed, making it an unauthenticated path + ("app/../entrypoint.js", HTTPStatus.UNAUTHORIZED), + ("app/%2E%2E/entrypoint.js", HTTPStatus.UNAUTHORIZED), + # Unauthenticated path + ("supervisor/info", HTTPStatus.UNAUTHORIZED), + ], +) +async def test_forward_request_admin_unallowed_paths( + hassio_client, + aioclient_mock: AiohttpClientMocker, + bad_path: str, + expected_status: int, +) -> None: + """Test fetching normal path.""" + resp = await hassio_client.get(f"/api/hassio/{bad_path}") + + # Check we got right response + assert resp.status == expected_status + # Check we didn't forward command + assert len(aioclient_mock.mock_calls) == 0 async def test_bad_gateway_when_cannot_find_supervisor( hassio_client, aioclient_mock: AiohttpClientMocker ) -> None: """Test we get a bad gateway error if we can't find supervisor.""" - aioclient_mock.get("http://127.0.0.1/addons/test/info", exc=asyncio.TimeoutError) + aioclient_mock.get("http://127.0.0.1/app/entrypoint.js", exc=asyncio.TimeoutError) - resp = await hassio_client.get("/api/hassio/addons/test/info") + resp = await hassio_client.get("/api/hassio/app/entrypoint.js") assert resp.status == HTTPStatus.BAD_GATEWAY -async def test_forwarding_user_info( - hassio_client, hass_admin_user: MockUser, aioclient_mock: AiohttpClientMocker -) -> None: - """Test that we forward user info correctly.""" - aioclient_mock.get("http://127.0.0.1/hello") - - resp = await hassio_client.get("/api/hassio/hello") - - # Check we got right response - assert resp.status == HTTPStatus.OK - - assert len(aioclient_mock.mock_calls) == 1 - - req_headers = aioclient_mock.mock_calls[0][-1] - assert req_headers["X-Hass-User-ID"] == hass_admin_user.id - assert req_headers["X-Hass-Is-Admin"] == "1" - - async def test_backup_upload_headers( - hassio_client, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture + hassio_client, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, + mock_not_onboarded, ) -> None: """Test that we forward the full header for backup upload.""" content_type = "multipart/form-data; boundary='--webkit'" - aioclient_mock.get("http://127.0.0.1/backups/new/upload") + aioclient_mock.post("http://127.0.0.1/backups/new/upload") - resp = await hassio_client.get( + resp = await hassio_client.post( "/api/hassio/backups/new/upload", headers={"Content-Type": content_type} ) @@ -168,19 +424,19 @@ async def test_backup_upload_headers( async def test_backup_download_headers( - hassio_client, aioclient_mock: AiohttpClientMocker + hassio_client, aioclient_mock: AiohttpClientMocker, mock_not_onboarded ) -> None: """Test that we forward the full header for backup download.""" content_disposition = "attachment; filename=test.tar" aioclient_mock.get( - "http://127.0.0.1/backups/slug/download", + "http://127.0.0.1/backups/1234abcd/download", headers={ "Content-Length": "50000000", "Content-Disposition": content_disposition, }, ) - resp = await hassio_client.get("/api/hassio/backups/slug/download") + resp = await hassio_client.get("/api/hassio/backups/1234abcd/download") # Check we got right response assert resp.status == HTTPStatus.OK @@ -190,21 +446,10 @@ async def test_backup_download_headers( assert resp.headers["Content-Disposition"] == content_disposition -def test_need_auth(hass: HomeAssistant) -> None: - """Test if the requested path needs authentication.""" - assert not _need_auth(hass, "addons/test/logo") - assert _need_auth(hass, "backups/new/upload") - assert _need_auth(hass, "supervisor/logs") - - hass.data["onboarding"] = False - assert not _need_auth(hass, "backups/new/upload") - assert not _need_auth(hass, "supervisor/logs") - - async def test_stream(hassio_client, aioclient_mock: AiohttpClientMocker) -> None: """Verify that the request is a stream.""" - aioclient_mock.get("http://127.0.0.1/test") - await hassio_client.get("/api/hassio/test", data="test") + aioclient_mock.get("http://127.0.0.1/app/entrypoint.js") + await hassio_client.get("/api/hassio/app/entrypoint.js", data="test") assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader) diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 52ca535516a0..67548a19c2c7 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -21,7 +21,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker ], ) async def test_ingress_request_get( - hassio_client, build_type, aioclient_mock: AiohttpClientMocker + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker ) -> None: """Test no auth needed for .""" aioclient_mock.get( @@ -29,7 +29,7 @@ async def test_ingress_request_get( text="test", ) - resp = await hassio_client.get( + resp = await hassio_noauth_client.get( f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", headers={"X-Test-Header": "beer"}, ) @@ -41,7 +41,8 @@ async def test_ingress_request_get( # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -63,7 +64,7 @@ async def test_ingress_request_get( ], ) async def test_ingress_request_post( - hassio_client, build_type, aioclient_mock: AiohttpClientMocker + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker ) -> None: """Test no auth needed for .""" aioclient_mock.post( @@ -71,7 +72,7 @@ async def test_ingress_request_post( text="test", ) - resp = await hassio_client.post( + resp = await hassio_noauth_client.post( f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", headers={"X-Test-Header": "beer"}, ) @@ -83,7 +84,8 @@ async def test_ingress_request_post( # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -105,7 +107,7 @@ async def test_ingress_request_post( ], ) async def test_ingress_request_put( - hassio_client, build_type, aioclient_mock: AiohttpClientMocker + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker ) -> None: """Test no auth needed for .""" aioclient_mock.put( @@ -113,7 +115,7 @@ async def test_ingress_request_put( text="test", ) - resp = await hassio_client.put( + resp = await hassio_noauth_client.put( f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", headers={"X-Test-Header": "beer"}, ) @@ -125,7 +127,8 @@ async def test_ingress_request_put( # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -147,7 +150,7 @@ async def test_ingress_request_put( ], ) async def test_ingress_request_delete( - hassio_client, build_type, aioclient_mock: AiohttpClientMocker + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker ) -> None: """Test no auth needed for .""" aioclient_mock.delete( @@ -155,7 +158,7 @@ async def test_ingress_request_delete( text="test", ) - resp = await hassio_client.delete( + resp = await hassio_noauth_client.delete( f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", headers={"X-Test-Header": "beer"}, ) @@ -167,7 +170,8 @@ async def test_ingress_request_delete( # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -189,7 +193,7 @@ async def test_ingress_request_delete( ], ) async def test_ingress_request_patch( - hassio_client, build_type, aioclient_mock: AiohttpClientMocker + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker ) -> None: """Test no auth needed for .""" aioclient_mock.patch( @@ -197,7 +201,7 @@ async def test_ingress_request_patch( text="test", ) - resp = await hassio_client.patch( + resp = await hassio_noauth_client.patch( f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", headers={"X-Test-Header": "beer"}, ) @@ -209,7 +213,8 @@ async def test_ingress_request_patch( # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -231,7 +236,7 @@ async def test_ingress_request_patch( ], ) async def test_ingress_request_options( - hassio_client, build_type, aioclient_mock: AiohttpClientMocker + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker ) -> None: """Test no auth needed for .""" aioclient_mock.options( @@ -239,7 +244,7 @@ async def test_ingress_request_options( text="test", ) - resp = await hassio_client.options( + resp = await hassio_noauth_client.options( f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", headers={"X-Test-Header": "beer"}, ) @@ -251,7 +256,8 @@ async def test_ingress_request_options( # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -273,20 +279,21 @@ async def test_ingress_request_options( ], ) async def test_ingress_websocket( - hassio_client, build_type, aioclient_mock: AiohttpClientMocker + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker ) -> None: """Test no auth needed for .""" aioclient_mock.get(f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}") # Ignore error because we can setup a full IO infrastructure - await hassio_client.ws_connect( + await hassio_noauth_client.ws_connect( f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", headers={"X-Test-Header": "beer"}, ) # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -298,7 +305,9 @@ async def test_ingress_websocket( async def test_ingress_missing_peername( - hassio_client, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture + hassio_noauth_client, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, ) -> None: """Test hadnling of missing peername.""" aioclient_mock.get( @@ -314,7 +323,7 @@ async def test_ingress_missing_peername( return_value=MagicMock(), ) as transport_mock: transport_mock.get_extra_info = get_extra_info - resp = await hassio_client.get( + resp = await hassio_noauth_client.get( "/api/hassio_ingress/lorem/ipsum", headers={"X-Test-Header": "beer"}, ) @@ -323,3 +332,19 @@ async def test_ingress_missing_peername( # Check we got right response assert resp.status == HTTPStatus.BAD_REQUEST + + +async def test_forwarding_paths_as_requested( + hassio_noauth_client, aioclient_mock +) -> None: + """Test incomnig URLs with double encoding go out as dobule encoded.""" + # This double encoded string should be forwarded double-encoded too. + aioclient_mock.get( + "http://127.0.0.1/ingress/mock-token/hello/%252e./world", + text="test", + ) + + resp = await hassio_noauth_client.get( + "/api/hassio_ingress/mock-token/hello/%252e./world", + ) + assert await resp.text() == "test" diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 611ada61814c..b2f9e06cb43c 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -153,6 +153,11 @@ async def test_websocket_supervisor_api( msg = await websocket_client.receive_json() assert msg["result"]["version_latest"] == "1.0.0" + assert aioclient_mock.mock_calls[-1][3] == { + "X-Hass-Source": "core.websocket_api", + "Authorization": "Bearer 123456", + } + async def test_websocket_supervisor_api_error( hassio_env, From 2626dd2c83182fa238f078f51f8cb123b7fe1011 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 15:24:19 +0100 Subject: [PATCH 0306/1058] Fix invalid state class in litterrobot (#89380) --- homeassistant/components/litterrobot/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 4c63f1c3fa81..e7aed366fa3e 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -140,7 +140,7 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { name="Pet weight", native_unit_of_measurement=UnitOfMass.POUNDS, device_class=SensorDeviceClass.WEIGHT, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, ), ], FeederRobot: [ From 5374c70c978ca30c9ea62b586f39bfde73fea5a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 04:27:34 -1000 Subject: [PATCH 0307/1058] Fix bluetooth history and device expire running in the executor (#89342) --- homeassistant/components/bluetooth/base_scanner.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 00cc9fff0fe2..903f14a92273 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -227,20 +227,21 @@ class BaseHaRemoteScanner(BaseHaScanner): self.hass, self._async_expire_devices, timedelta(seconds=30) ) cancel_stop = self.hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self._save_history + EVENT_HOMEASSISTANT_STOP, self._async_save_history ) self._async_setup_scanner_watchdog() @hass_callback def _cancel() -> None: - self._save_history() + self._async_save_history() self._async_stop_scanner_watchdog() cancel_track() cancel_stop() return _cancel - def _save_history(self, event: Event | None = None) -> None: + @hass_callback + def _async_save_history(self, event: Event | None = None) -> None: """Save the history.""" self._storage.async_set_advertisement_history( self.source, @@ -252,6 +253,7 @@ class BaseHaRemoteScanner(BaseHaScanner): ), ) + @hass_callback def _async_expire_devices(self, _datetime: datetime.datetime) -> None: """Expire old devices.""" now = MONOTONIC_TIME() From 2ec78ae70e7d7ee029fd0690d9073507a1979a9a Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Wed, 8 Mar 2023 06:37:24 -0800 Subject: [PATCH 0308/1058] Recreate iaqualink httpx client upon service exception (#89341) --- homeassistant/components/iaqualink/__init__.py | 1 + homeassistant/components/iaqualink/utils.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index cbdf909001aa..225953035a2a 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -153,6 +153,7 @@ async def async_setup_entry( # noqa: C901 system.serial, svc_exception, ) + await system.aqualink.close() else: cur = system.online if cur and not prev: diff --git a/homeassistant/components/iaqualink/utils.py b/homeassistant/components/iaqualink/utils.py index b047af5869c9..87bc863a7f83 100644 --- a/homeassistant/components/iaqualink/utils.py +++ b/homeassistant/components/iaqualink/utils.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable +import httpx from iaqualink.exception import AqualinkServiceException from homeassistant.exceptions import HomeAssistantError @@ -12,5 +13,5 @@ async def await_or_reraise(awaitable: Awaitable) -> None: """Execute API call while catching service exceptions.""" try: await awaitable - except AqualinkServiceException as svc_exception: + except (AqualinkServiceException, httpx.HTTPError) as svc_exception: raise HomeAssistantError(f"Aqualink error: {svc_exception}") from svc_exception From ea6a95176de6dfbd85da61a772a80cb7aaca5876 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:16:28 +0100 Subject: [PATCH 0309/1058] Add missing mock in azure event hub config flow tests (#89355) --- tests/components/azure_event_hub/conftest.py | 2 +- tests/components/azure_event_hub/test_config_flow.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/components/azure_event_hub/conftest.py b/tests/components/azure_event_hub/conftest.py index 18f44b10480b..1af24d4a6fad 100644 --- a/tests/components/azure_event_hub/conftest.py +++ b/tests/components/azure_event_hub/conftest.py @@ -117,7 +117,7 @@ def mock_from_connection_string_fixture(): yield from_conn_string -@pytest.fixture(name="mock_setup_entry") +@pytest.fixture def mock_setup_entry(): """Mock the setup entry call, used for config flow tests.""" with patch( diff --git a/tests/components/azure_event_hub/test_config_flow.py b/tests/components/azure_event_hub/test_config_flow.py index f49f6470a996..8cebbe6fbd48 100644 --- a/tests/components/azure_event_hub/test_config_flow.py +++ b/tests/components/azure_event_hub/test_config_flow.py @@ -1,5 +1,6 @@ """Test the AEH config flow.""" import logging +from unittest.mock import AsyncMock from azure.eventhub.exceptions import EventHubError import pytest @@ -29,6 +30,8 @@ from tests.common import MockConfigEntry _LOGGER = logging.getLogger(__name__) +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.mark.parametrize( ("step1_config", "step_id", "step2_config", "data_config"), @@ -40,7 +43,7 @@ _LOGGER = logging.getLogger(__name__) ) async def test_form( hass: HomeAssistant, - mock_setup_entry, + mock_setup_entry: AsyncMock, mock_from_connection_string, step1_config, step_id, @@ -70,7 +73,7 @@ async def test_form( mock_setup_entry.assert_called_once() -async def test_import(hass: HomeAssistant, mock_setup_entry) -> None: +async def test_import(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" import_config = IMPORT_CONFIG.copy() From f4572a2e1c5dc8e76d87e63e16a61d192793f044 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:16:51 +0100 Subject: [PATCH 0310/1058] Add missing mock in atag config flow tests (#89356) --- tests/components/atag/conftest.py | 12 +++++++++++- tests/components/atag/test_config_flow.py | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/components/atag/conftest.py b/tests/components/atag/conftest.py index 7df3bfecf11e..a1270696540e 100644 --- a/tests/components/atag/conftest.py +++ b/tests/components/atag/conftest.py @@ -1,10 +1,20 @@ """Provide common Atag fixtures.""" import asyncio -from unittest.mock import patch +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.atag.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) async def mock_pyatag_sleep(): """Mock out pyatag sleeps.""" diff --git a/tests/components/atag/test_config_flow.py b/tests/components/atag/test_config_flow.py index 87435b66a77c..8dc73741e90e 100644 --- a/tests/components/atag/test_config_flow.py +++ b/tests/components/atag/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Atag config flow.""" from unittest.mock import PropertyMock, patch +import pytest + from homeassistant import config_entries, data_entry_flow from homeassistant.components.atag import DOMAIN from homeassistant.core import HomeAssistant @@ -9,6 +11,8 @@ from . import UID, USER_INPUT, init_integration, mock_connection from tests.test_util.aiohttp import AiohttpClientMocker +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -27,7 +31,9 @@ async def test_adding_second_device( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that only one Atag configuration is allowed.""" - await init_integration(hass, aioclient_mock) + entry = await init_integration(hass, aioclient_mock) + entry.unique_id = UID + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT ) From b61ad43144d86f3518fff513f5ccdaa2bb11cf7e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:17:45 +0100 Subject: [PATCH 0311/1058] Add missing mock in amber config flow tests (#89358) --- tests/components/amberelectric/conftest.py | 14 ++++++++++++++ tests/components/amberelectric/test_config_flow.py | 2 ++ 2 files changed, 16 insertions(+) create mode 100644 tests/components/amberelectric/conftest.py diff --git a/tests/components/amberelectric/conftest.py b/tests/components/amberelectric/conftest.py new file mode 100644 index 000000000000..f7d7d6623e14 --- /dev/null +++ b/tests/components/amberelectric/conftest.py @@ -0,0 +1,14 @@ +"""Provide common Amber fixtures.""" +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.amberelectric.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/amberelectric/test_config_flow.py b/tests/components/amberelectric/test_config_flow.py index 2be77f19bf1e..6325282aff81 100644 --- a/tests/components/amberelectric/test_config_flow.py +++ b/tests/components/amberelectric/test_config_flow.py @@ -20,6 +20,8 @@ from homeassistant.core import HomeAssistant API_KEY = "psk_123456789" +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.fixture(name="invalid_key_api") def mock_invalid_key_api() -> Generator: From 959c2205d511aa7991ee209cf6e9d7549595b363 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:07 +0100 Subject: [PATCH 0312/1058] Add missing mock in airvisual config flow tests (#89359) --- tests/components/airvisual/conftest.py | 10 ++++++++++ tests/components/airvisual/test_config_flow.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/tests/components/airvisual/conftest.py b/tests/components/airvisual/conftest.py index f6c1619d6d38..bdd325d4739e 100644 --- a/tests/components/airvisual/conftest.py +++ b/tests/components/airvisual/conftest.py @@ -1,4 +1,5 @@ """Define test fixtures for AirVisual.""" +from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch @@ -141,3 +142,12 @@ async def setup_config_entry_fixture(hass, config_entry, mock_pyairvisual): """Define a fixture to set up airvisual.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airvisual.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index b07a17972f72..1761f55d17f2 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -32,6 +32,8 @@ from .conftest import ( TEST_STATE, ) +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.mark.parametrize( ("integration_type", "input_form_step", "patched_method", "config", "entry_title"), From 23698eb99fcbf091bab82cbfb22cb3dfea9e17d9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:17 +0100 Subject: [PATCH 0313/1058] Add missing mock in agent_dvr config flow tests (#89361) --- tests/components/agent_dvr/conftest.py | 14 ++++++++++++++ tests/components/agent_dvr/test_config_flow.py | 4 ++++ 2 files changed, 18 insertions(+) create mode 100644 tests/components/agent_dvr/conftest.py diff --git a/tests/components/agent_dvr/conftest.py b/tests/components/agent_dvr/conftest.py new file mode 100644 index 000000000000..da2cd90ed183 --- /dev/null +++ b/tests/components/agent_dvr/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for Agent DVR.""" +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.agent_dvr.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/agent_dvr/test_config_flow.py b/tests/components/agent_dvr/test_config_flow.py index b36044e45b1b..9d1be278ada2 100644 --- a/tests/components/agent_dvr/test_config_flow.py +++ b/tests/components/agent_dvr/test_config_flow.py @@ -1,4 +1,6 @@ """Tests for the Agent DVR config flow.""" +import pytest + from homeassistant import data_entry_flow from homeassistant.components.agent_dvr import config_flow from homeassistant.components.agent_dvr.const import SERVER_URL @@ -11,6 +13,8 @@ from . import init_integration from tests.common import load_fixture from tests.test_util.aiohttp import AiohttpClientMocker +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_user_form(hass: HomeAssistant) -> None: """Test that the user set up form is served.""" From 3a40f5f35b691c728b16ca5486139b9c59d5aef9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:24 +0100 Subject: [PATCH 0314/1058] Add missing mock in airvisual_pro config flow tests (#89362) --- tests/components/airvisual_pro/conftest.py | 10 ++++++++++ tests/components/airvisual_pro/test_config_flow.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py index 5846a988688b..caff9571812d 100644 --- a/tests/components/airvisual_pro/conftest.py +++ b/tests/components/airvisual_pro/conftest.py @@ -1,4 +1,5 @@ """Define test fixtures for AirVisual Pro.""" +from collections.abc import Generator import json from unittest.mock import AsyncMock, Mock, patch @@ -11,6 +12,15 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airvisual_pro.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" diff --git a/tests/components/airvisual_pro/test_config_flow.py b/tests/components/airvisual_pro/test_config_flow.py index 8ffc3d4d5b61..f1c6d93e357b 100644 --- a/tests/components/airvisual_pro/test_config_flow.py +++ b/tests/components/airvisual_pro/test_config_flow.py @@ -14,6 +14,8 @@ from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_US from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.mark.parametrize( ("connect_mock", "connect_errors"), From 33906059d361d9c20eb4e97e937dc552428e76f8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:31 +0100 Subject: [PATCH 0315/1058] Add missing mock in airq config flow tests (#89364) --- tests/components/airq/conftest.py | 14 ++++++++++++++ tests/components/airq/test_config_flow.py | 3 +++ 2 files changed, 17 insertions(+) create mode 100644 tests/components/airq/conftest.py diff --git a/tests/components/airq/conftest.py b/tests/components/airq/conftest.py new file mode 100644 index 000000000000..28053c9c20a8 --- /dev/null +++ b/tests/components/airq/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for air-Q.""" +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.airq.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 38fc15fdae3c..52bd5cd37fd7 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from aioairq.core import DeviceInfo, InvalidAuth, InvalidInput from aiohttp.client_exceptions import ClientConnectionError +import pytest from homeassistant import config_entries from homeassistant.components.airq.const import DOMAIN @@ -10,6 +11,8 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + TEST_USER_DATA = { CONF_IP_ADDRESS: "192.168.0.0", CONF_PASSWORD: "password", From 3e2ee7cd11022a9fa2adf24c2d0f4951b1893a8c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:18:44 +0100 Subject: [PATCH 0316/1058] Add missing mock in aemet config flow tests (#89360) --- tests/components/aemet/conftest.py | 14 ++++++++++++++ tests/components/aemet/test_config_flow.py | 12 ++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 tests/components/aemet/conftest.py diff --git a/tests/components/aemet/conftest.py b/tests/components/aemet/conftest.py new file mode 100644 index 000000000000..606f01c54039 --- /dev/null +++ b/tests/components/aemet/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for aemet.""" +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.aemet.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index 9abf626a5d0e..8ec16d313f76 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -1,6 +1,7 @@ """Define tests for the AEMET OpenData config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +import pytest import requests_mock from homeassistant import data_entry_flow @@ -14,6 +15,8 @@ from .util import aemet_requests_mock from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + CONFIG = { CONF_NAME: "aemet", CONF_API_KEY: "foo", @@ -22,13 +25,10 @@ CONFIG = { } -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that the form is served with valid input.""" - with patch( - "homeassistant.components.aemet.async_setup_entry", - return_value=True, - ) as mock_setup_entry, requests_mock.mock() as _m: + with requests_mock.mock() as _m: aemet_requests_mock(_m) result = await hass.config_entries.flow.async_init( From 4ce36366c3d9a21c5a39590294ba1671e58c813a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 05:19:36 -1000 Subject: [PATCH 0317/1058] Add names to the config entry setup and shutdown tasks (#89309) * name the entry setup tasks * name a few more tasks * Update homeassistant/config_entries.py * Update homeassistant/setup.py --- homeassistant/config_entries.py | 29 +++++++++++++++++++++++++---- homeassistant/setup.py | 5 ++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 29788a678ad7..41cccdf9969c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1131,7 +1131,13 @@ class ConfigEntries: async def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" await asyncio.gather( - *(entry.async_shutdown() for entry in self._entries.values()) + *( + asyncio.create_task( + entry.async_shutdown(), + name=f"config entry shutdown {entry.title} {entry.domain} {entry.entry_id}", + ) + for entry in self._entries.values() + ) ) await self.flow.async_shutdown() @@ -1390,7 +1396,13 @@ class ConfigEntries: ) -> None: """Forward the setup of an entry to platforms.""" await asyncio.gather( - *(self.async_forward_entry_setup(entry, platform) for platform in platforms) + *( + asyncio.create_task( + self.async_forward_entry_setup(entry, platform), + name=f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", + ) + for platform in platforms + ) ) async def async_forward_entry_setup( @@ -1421,7 +1433,10 @@ class ConfigEntries: return all( await asyncio.gather( *( - self.async_forward_entry_unload(entry, platform) + asyncio.create_task( + self.async_forward_entry_unload(entry, platform), + name=f"config entry forward unload {entry.title} {entry.domain} {entry.entry_id} {platform}", + ) for platform in platforms ) ) @@ -1952,7 +1967,13 @@ class EntityRegistryDisabledHandler: ) await asyncio.gather( - *(self.hass.config_entries.async_reload(entry_id) for entry_id in to_reload) + *( + asyncio.create_task( + self.hass.config_entries.async_reload(entry_id), + name="config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + for entry_id in to_reload + ) ) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index df5d8257083d..ce502116cf2c 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -295,7 +295,10 @@ async def _async_setup_component( await asyncio.gather( *( - entry.async_setup(hass, integration=integration) + asyncio.create_task( + entry.async_setup(hass, integration=integration), + name=f"config entry setup {entry.title} {entry.domain} {entry.entry_id}", + ) for entry in hass.config_entries.async_entries(domain) ) ) From 614a1b03c12547912c245887e8e6f9d15e2be94c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 05:23:13 -1000 Subject: [PATCH 0318/1058] Use an event filter for event triggers (#89339) We avoid the overhead of call_soon and event loop scheduling if the event does not match the schema --- .../components/homeassistant/triggers/event.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index 0796d49d770b..d84b04c36527 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -80,11 +80,11 @@ async def async_attach_trigger( extra=vol.ALLOW_EXTRA, ) - job = HassJob(action) + job = HassJob(action, f"event trigger {trigger_info}") @callback - def handle_event(event: Event) -> None: - """Listen for events and calls the action when data matches.""" + def filter_event(event: Event) -> bool: + """Filter events.""" try: # Check that the event data and context match the configured # schema if one was provided @@ -94,8 +94,12 @@ async def async_attach_trigger( event_context_schema(event.context.as_dict()) except vol.Invalid: # If event doesn't match, skip event - return + return False + return True + @callback + def handle_event(event: Event) -> None: + """Listen for events and calls the action when data matches.""" hass.async_run_hass_job( job, { @@ -110,7 +114,8 @@ async def async_attach_trigger( ) removes = [ - hass.bus.async_listen(event_type, handle_event) for event_type in event_types + hass.bus.async_listen(event_type, handle_event, event_filter=filter_event) + for event_type in event_types ] @callback From aff7345ea09d2a0e7ed6e5be11ae97b8af01f2b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 05:25:42 -1000 Subject: [PATCH 0319/1058] Improve event filters to reject earlier (#89337) * Improve event filters to reject earlier - Avoid running the callbacks for state added/removed from a domain if there are no listeners that care about the domain - Remove some impossible checks in the listeners that will never match since they were already rejected by the filter * leave one guard since there is a race when we return control via await --- homeassistant/helpers/event.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index a924d9cb88bb..edbb5fa73546 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -412,18 +412,21 @@ def async_track_entity_registry_updated_event( return remove_listener +@callback +def _async_domain_has_listeners( + domain: str, callbacks: dict[str, list[HassJob[[Event], Any]]] +) -> bool: + """Check if the domain has any listeners.""" + return domain in callbacks or MATCH_ALL in callbacks + + @callback def _async_dispatch_domain_event( hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[[Event], Any]]] ) -> None: + """Dispatch domain event listeners.""" domain = split_entity_id(event.data["entity_id"])[0] - - if domain not in callbacks and MATCH_ALL not in callbacks: - return - - listeners = callbacks.get(domain, []) + callbacks.get(MATCH_ALL, []) - - for job in listeners: + for job in callbacks.get(domain, []) + callbacks.get(MATCH_ALL, []): try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except @@ -460,14 +463,13 @@ def _async_track_state_added_domain( @callback def _async_state_change_filter(event: Event) -> bool: """Filter state changes by entity_id.""" - return event.data.get("old_state") is None + return event.data.get("old_state") is None and _async_domain_has_listeners( + split_entity_id(event.data["entity_id"])[0], domain_callbacks + ) @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" - if event.data.get("old_state") is not None: - return - _async_dispatch_domain_event(hass, event, domain_callbacks) hass.data[TRACK_STATE_ADDED_DOMAIN_LISTENER] = hass.bus.async_listen( @@ -514,14 +516,13 @@ def async_track_state_removed_domain( @callback def _async_state_change_filter(event: Event) -> bool: """Filter state changes by entity_id.""" - return event.data.get("new_state") is None + return event.data.get("new_state") is None and _async_domain_has_listeners( + split_entity_id(event.data["entity_id"])[0], domain_callbacks + ) @callback def _async_state_change_dispatcher(event: Event) -> None: """Dispatch state changes by entity_id.""" - if event.data.get("new_state") is not None: - return - _async_dispatch_domain_event(hass, event, domain_callbacks) hass.data[TRACK_STATE_REMOVED_DOMAIN_LISTENER] = hass.bus.async_listen( From 7982f713e156a44439f35edaf87a536305e59997 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:53:08 +0100 Subject: [PATCH 0320/1058] Fix lingering tasks in plex (#89282) * Cleanup expected_lingering_tasks in plex * Adjust --- tests/components/plex/test_config_flow.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 0bc2d04b41af..beb454e2e9c4 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -168,10 +168,11 @@ async def test_no_servers_found( assert result["errors"]["base"] == "no_servers" -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_single_available_server( - hass: HomeAssistant, mock_plex_calls, current_request_with_host: None + hass: HomeAssistant, + mock_plex_calls, + current_request_with_host: None, + mock_setup_entry: AsyncMock, ) -> None: """Test creating an entry with one server available.""" result = await hass.config_entries.flow.async_init( @@ -205,17 +206,16 @@ async def test_single_available_server( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN - await hass.config_entries.async_unload(result["result"].entry_id) + mock_setup_entry.assert_called_once() -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_multiple_servers_with_selection( hass: HomeAssistant, mock_plex_calls, requests_mock: requests_mock.Mocker, plextv_resources_two_servers, current_request_with_host: None, + mock_setup_entry: AsyncMock, ) -> None: """Test creating an entry with multiple servers available.""" result = await hass.config_entries.flow.async_init( @@ -262,17 +262,16 @@ async def test_multiple_servers_with_selection( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN - await hass.config_entries.async_unload(result["result"].entry_id) + mock_setup_entry.assert_called_once() -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_adding_last_unconfigured_server( hass: HomeAssistant, mock_plex_calls, requests_mock: requests_mock.Mocker, plextv_resources_two_servers, current_request_with_host: None, + mock_setup_entry: AsyncMock, ) -> None: """Test automatically adding last unconfigured server when multiple servers on account.""" MockConfigEntry( @@ -319,7 +318,7 @@ async def test_adding_last_unconfigured_server( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN - await hass.config_entries.async_unload(result["result"].entry_id) + assert mock_setup_entry.call_count == 2 async def test_all_available_servers_configured( From bfb89fd8f2cb440380d7814eeed2817817dbf3e4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:54:19 +0100 Subject: [PATCH 0321/1058] Update pylint to 2.17.0 (#89377) * Update pylint to 2.17.0 * Remove unused pylint disable comments --- homeassistant/components/recorder/statistics.py | 5 +---- homeassistant/components/reolink/__init__.py | 2 +- homeassistant/components/thread/diagnostics.py | 4 ++-- homeassistant/runner.py | 2 +- requirements_test.txt | 4 ++-- 5 files changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 4cc6e40fa695..48bab4b11fd7 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1940,10 +1940,7 @@ def _latest_short_term_statistics_stmt( .group_by(StatisticsShortTerm.metadata_id) ).subquery() ), - ( - StatisticsShortTerm.metadata_id # pylint: disable=comparison-with-callable - == most_recent_statistic_row.c.metadata_id - ) + (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), ) return stmt diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index c3d8df61f5af..76c0963e2c06 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b raise ConfigEntryNotReady( f"Error while trying to setup {host.api.host}:{host.api.port}: {str(err)}" ) from err - except Exception: # pylint: disable=broad-except + except Exception: await host.stop() raise diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index eb1e2a5ef681..8dc5dd43041e 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -29,7 +29,7 @@ from .dataset_store import async_get_store from .discovery import async_read_zeroconf_cache if TYPE_CHECKING: - from pyroute2 import NDB # pylint: disable=no-name-in-module + from pyroute2 import NDB class Neighbour(TypedDict): @@ -121,7 +121,7 @@ def _get_routes_and_neighbors(): NDB, ) - with NDB() as ndb: # pylint: disable=not-callable + with NDB() as ndb: routes, reverse_routes = _get_possible_thread_routes(ndb) neighbours = _get_neighbours(ndb) diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 8c5766cbb2ba..0926fb67459e 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -62,7 +62,7 @@ def can_use_pidfd() -> bool: return False try: pid = os.getpid() - os.close(os.pidfd_open(pid, 0)) # pylint: disable=no-member + os.close(os.pidfd_open(pid, 0)) except OSError: # blocked by security policy like SECCOMP return False diff --git a/requirements_test.txt b/requirements_test.txt index 9e6ded1f3953..f5be4d075972 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.14.2 +astroid==2.15.0 codecov==2.1.12 coverage==7.2.1 freezegun==1.2.2 @@ -15,7 +15,7 @@ mock-open==1.4.0 mypy==1.0.1 pre-commit==3.1.0 pydantic==1.10.5 -pylint==2.16.4 +pylint==2.17.0 pylint-per-file-ignores==1.1.0 pipdeptree==2.5.0 pytest-asyncio==0.20.3 From b0013247ff5e00874706a90f69e95568c0cee821 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Wed, 8 Mar 2023 16:56:04 +0100 Subject: [PATCH 0322/1058] Move Landis+Gyr sensor descriptions to sensor platform (#89382) Move HEAT_METER_SENSOR_TYPES to sensor platform --- .../components/landisgyr_heat_meter/const.py | 206 ------------------ .../components/landisgyr_heat_meter/sensor.py | 204 ++++++++++++++++- 2 files changed, 203 insertions(+), 207 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index bded296f3f4e..5d27a8a17052 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -1,212 +1,6 @@ """Constants for the Landis+Gyr Heat Meter integration.""" -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - EntityCategory, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, - UnitOfVolume, - UnitOfVolumeFlowRate, -) - DOMAIN = "landisgyr_heat_meter" GJ_TO_MWH = 0.277778 # conversion factor ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time - -HEAT_METER_SENSOR_TYPES = ( - SensorEntityDescription( - key="heat_usage", - icon="mdi:fire", - name="Heat usage", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - ), - SensorEntityDescription( - key="volume_usage_m3", - icon="mdi:fire", - name="Volume usage", - device_class=SensorDeviceClass.VOLUME, - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - state_class=SensorStateClass.TOTAL, - ), - # Diagnostic entity for debugging, this will match the value in GJ indicated on the meter's display - SensorEntityDescription( - key="heat_usage_gj", - icon="mdi:fire", - name="Heat usage GJ", - native_unit_of_measurement="GJ", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="heat_previous_year", - icon="mdi:fire", - name="Heat usage previous year", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, - ), - # Diagnostic entity for debugging, this will match the value in GJ of previous year indicated on the meter's display - SensorEntityDescription( - key="heat_previous_year_gj", - icon="mdi:fire", - name="Heat previous year GJ", - native_unit_of_measurement="GJ", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="volume_previous_year_m3", - icon="mdi:fire", - name="Volume usage previous year", - device_class=SensorDeviceClass.VOLUME, - native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="ownership_number", - name="Ownership number", - icon="mdi:identifier", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="error_number", - name="Error number", - icon="mdi:home-alert", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="device_number", - name="Device number", - icon="mdi:identifier", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="measurement_period_minutes", - name="Measurement period minutes", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="power_max_kw", - name="Power max", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="power_max_previous_year_kw", - name="Power max previous year", - native_unit_of_measurement=UnitOfPower.KILO_WATT, - device_class=SensorDeviceClass.POWER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flowrate_max_m3ph", - name="Flowrate max", - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - icon="mdi:water-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flowrate_max_previous_year_m3ph", - name="Flowrate max previous year", - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - icon="mdi:water-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="return_temperature_max_c", - name="Return temperature max", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="return_temperature_max_previous_year_c", - name="Return temperature max previous year", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flow_temperature_max_c", - name="Flow temperature max", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flow_temperature_max_previous_year_c", - name="Flow temperature max previous year", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="operating_hours", - name="Operating hours", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="flow_hours", - name="Flow hours", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="fault_hours", - name="Fault hours", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="fault_hours_previous_year", - name="Fault hours previous year", - device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.HOURS, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="yearly_set_day", - name="Yearly set day", - icon="mdi:clock-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="monthly_set_day", - name="Monthly set day", - icon="mdi:clock-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="meter_date_time", - name="Meter date time", - icon="mdi:clock-outline", - device_class=SensorDeviceClass.TIMESTAMP, - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="measuring_range_m3ph", - name="Measuring range", - native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, - icon="mdi:water-outline", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="settings_and_firmware", - name="Settings and firmware", - entity_category=EntityCategory.DIAGNOSTIC, - ), -) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 284fb5b7f302..8ded9e4d7258 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -10,8 +10,18 @@ from homeassistant.components.sensor import ( RestoreSensor, SensorDeviceClass, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + EntityCategory, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,11 +32,203 @@ from homeassistant.helpers.update_coordinator import ( from homeassistant.util import dt as dt_util from . import DOMAIN -from .const import GJ_TO_MWH, HEAT_METER_SENSOR_TYPES +from .const import GJ_TO_MWH _LOGGER = logging.getLogger(__name__) +HEAT_METER_SENSOR_TYPES = ( + SensorEntityDescription( + key="heat_usage", + icon="mdi:fire", + name="Heat usage", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + key="volume_usage_m3", + icon="mdi:fire", + name="Volume usage", + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + state_class=SensorStateClass.TOTAL, + ), + # Diagnostic entity for debugging, this will match the value in GJ indicated on the meter's display + SensorEntityDescription( + key="heat_usage_gj", + icon="mdi:fire", + name="Heat usage GJ", + native_unit_of_measurement="GJ", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="heat_previous_year", + icon="mdi:fire", + name="Heat usage previous year", + native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + # Diagnostic entity for debugging, this will match the value in GJ of previous year indicated on the meter's display + SensorEntityDescription( + key="heat_previous_year_gj", + icon="mdi:fire", + name="Heat previous year GJ", + native_unit_of_measurement="GJ", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="volume_previous_year_m3", + icon="mdi:fire", + name="Volume usage previous year", + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="ownership_number", + name="Ownership number", + icon="mdi:identifier", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="error_number", + name="Error number", + icon="mdi:home-alert", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="device_number", + name="Device number", + icon="mdi:identifier", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="measurement_period_minutes", + name="Measurement period minutes", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_max_kw", + name="Power max", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="power_max_previous_year_kw", + name="Power max previous year", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flowrate_max_m3ph", + name="Flowrate max", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flowrate_max_previous_year_m3ph", + name="Flowrate max previous year", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="return_temperature_max_c", + name="Return temperature max", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="return_temperature_max_previous_year_c", + name="Return temperature max previous year", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flow_temperature_max_c", + name="Flow temperature max", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flow_temperature_max_previous_year_c", + name="Flow temperature max previous year", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="operating_hours", + name="Operating hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="flow_hours", + name="Flow hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="fault_hours", + name="Fault hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="fault_hours_previous_year", + name="Fault hours previous year", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="yearly_set_day", + name="Yearly set day", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="monthly_set_day", + name="Monthly set day", + icon="mdi:clock-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="meter_date_time", + name="Meter date time", + icon="mdi:clock-outline", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="measuring_range_m3ph", + name="Measuring range", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:water-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + key="settings_and_firmware", + name="Settings and firmware", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: From 18cb53a35c733bad4f31b64b6ee2f1397e6d9db1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Mar 2023 17:28:53 +0100 Subject: [PATCH 0323/1058] Pass hass instance when validating templates (#89242) * Pass hass instance when validating templates * Update tests * Fix validating templates without hass * Update service tests --- homeassistant/helpers/config_validation.py | 20 +++++- tests/helpers/test_config_validation.py | 73 +++++++++++++++++++++- tests/helpers/test_service.py | 18 +++--- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 42e1927e09b9..0f53c9108c89 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -85,7 +85,12 @@ from homeassistant.const import ( WEEKDAYS, UnitOfTemperature, ) -from homeassistant.core import split_entity_id, valid_entity_id +from homeassistant.core import ( + HomeAssistant, + async_get_hass, + split_entity_id, + valid_entity_id, +) from homeassistant.exceptions import TemplateError from homeassistant.generated import currencies from homeassistant.generated.countries import COUNTRIES @@ -597,7 +602,11 @@ def template(value: Any | None) -> template_helper.Template: if isinstance(value, (list, dict, template_helper.Template)): raise vol.Invalid("template value should be a string") - template_value = template_helper.Template(str(value)) + hass: HomeAssistant | None = None + with contextlib.suppress(LookupError): + hass = async_get_hass() + + template_value = template_helper.Template(str(value), hass) try: template_value.ensure_valid() @@ -615,7 +624,12 @@ def dynamic_template(value: Any | None) -> template_helper.Template: if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") - template_value = template_helper.Template(str(value)) + hass: HomeAssistant | None = None + with contextlib.suppress(LookupError): + hass = async_get_hass() + + template_value = template_helper.Template(str(value), hass) + try: template_value.ensure_valid() return template_value diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 6823e9655bd2..f1f644a36a70 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -561,11 +561,16 @@ def test_x10_address() -> None: schema("C11") -def test_template() -> None: +def test_template(hass: HomeAssistant) -> None: """Test template validator.""" schema = vol.Schema(cv.template) - for value in (None, "{{ partial_print }", "{% if True %}Hello", ["test"]): + for value in ( + None, + "{{ partial_print }", + "{% if True %}Hello", + ["test"], + ): with pytest.raises(vol.Invalid): schema(value) @@ -574,12 +579,43 @@ def test_template() -> None: "Hello", "{{ beer }}", "{% if 1 == 1 %}Hello{% else %}World{% endif %}", + # Function added as an extension by Home Assistant + "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + # Filter added as an extension by Home Assistant + "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", ) for value in options: schema(value) -def test_dynamic_template() -> None: +async def test_template_no_hass(hass: HomeAssistant) -> None: + """Test template validator.""" + schema = vol.Schema(cv.template) + + for value in ( + None, + "{{ partial_print }", + "{% if True %}Hello", + ["test"], + # Filter added as an extension by Home Assistant + "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", + ): + with pytest.raises(vol.Invalid): + await hass.async_add_executor_job(schema, value) + + options = ( + 1, + "Hello", + "{{ beer }}", + "{% if 1 == 1 %}Hello{% else %}World{% endif %}", + # Function added as an extension by Home Assistant + "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + ) + for value in options: + await hass.async_add_executor_job(schema, value) + + +def test_dynamic_template(hass: HomeAssistant) -> None: """Test dynamic template validator.""" schema = vol.Schema(cv.dynamic_template) @@ -597,11 +633,42 @@ def test_dynamic_template() -> None: options = ( "{{ beer }}", "{% if 1 == 1 %}Hello{% else %}World{% endif %}", + # Function added as an extension by Home Assistant + "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + # Filter added as an extension by Home Assistant + "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", ) for value in options: schema(value) +async def test_dynamic_template_no_hass(hass: HomeAssistant) -> None: + """Test dynamic template validator.""" + schema = vol.Schema(cv.dynamic_template) + + for value in ( + None, + 1, + "{{ partial_print }", + "{% if True %}Hello", + ["test"], + "just a string", + # Filter added as an extension by Home Assistant + "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", + ): + with pytest.raises(vol.Invalid): + await hass.async_add_executor_job(schema, value) + + options = ( + "{{ beer }}", + "{% if 1 == 1 %}Hello{% else %}World{% endif %}", + # Function added as an extension by Home Assistant + "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + ) + for value in options: + await hass.async_add_executor_job(schema, value) + + def test_template_complex() -> None: """Test template_complex validator.""" schema = vol.Schema(cv.template_complex) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index c3b5165bb142..43bbf85b06cf 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -383,16 +383,18 @@ async def test_split_entity_string(hass: HomeAssistant): async def test_not_mutate_input(hass: HomeAssistant): """Test for immutable input.""" async_mock_service(hass, "test_domain", "test_service") - config = cv.SERVICE_SCHEMA( - { - "service": "test_domain.test_service", - "entity_id": "hello.world, sensor.beer", - "data": {"hello": 1}, - "data_template": {"nested": {"value": "{{ 1 + 1 }}"}}, - } - ) + config = { + "service": "test_domain.test_service", + "entity_id": "hello.world, sensor.beer", + "data": {"hello": 1}, + "data_template": {"nested": {"value": "{{ 1 + 1 }}"}}, + } orig = deepcopy(config) + # Validate both the original and the copy + config = cv.SERVICE_SCHEMA(config) + orig = cv.SERVICE_SCHEMA(orig) + # Only change after call is each template getting hass attached template.attach(hass, orig) From 7d976538951ac8acf4138cbfa2271be9b3545953 Mon Sep 17 00:00:00 2001 From: parliament119 <34025831+parliament119@users.noreply.github.com> Date: Wed, 8 Mar 2023 18:24:37 +0100 Subject: [PATCH 0324/1058] Bump pyfritzhome to 0.6.8 and add support for Non-Color-Bulbs (#89141) --- homeassistant/components/fritzbox/light.py | 12 ++++-- .../components/fritzbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/fritzbox/__init__.py | 2 + tests/components/fritzbox/test_light.py | 42 +++++++++++++++++++ 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 24431f78aca1..f83dd4545924 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -72,8 +72,10 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): """Initialize the FritzboxLight entity.""" super().__init__(coordinator, ain, None) - self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) - self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) + if supported_color_temps: + # only available for color bulbs + self._attr_max_color_temp_kelvin = int(max(supported_color_temps)) + self._attr_min_color_temp_kelvin = int(min(supported_color_temps)) # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup @@ -125,7 +127,11 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity): @property def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" - return SUPPORTED_COLOR_MODES + if self.data.has_color: + return SUPPORTED_COLOR_MODES + if self.data.has_level: + return {ColorMode.BRIGHTNESS} + return {ColorMode.ONOFF} async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index e604f1d37b56..29df2f51a340 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], - "requirements": ["pyfritzhome==0.6.7"], + "requirements": ["pyfritzhome==0.6.8"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/requirements_all.txt b/requirements_all.txt index 78badcd39fa8..1821af222731 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1645,7 +1645,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.7 +pyfritzhome==0.6.8 # homeassistant.components.fronius pyfronius==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7176b1041897..f74895e3c6d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1182,7 +1182,7 @@ pyforked-daapd==0.1.14 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.6.7 +pyfritzhome==0.6.8 # homeassistant.components.fronius pyfronius==0.7.1 diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 34311c0aa555..6cf60a906567 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -151,6 +151,8 @@ class FritzDeviceLightMock(FritzEntityBaseMock): has_alarm = False has_powermeter = False has_lightbulb = True + has_color = True + has_level = True has_switch = False has_temperature_sensor = False has_thermostat = False diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 10c9835556be..074dd902fa1a 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_SUPPORTED_COLOR_MODES, DOMAIN, ) from homeassistant.const import ( @@ -57,6 +58,46 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 2700 assert state.attributes[ATTR_MIN_COLOR_TEMP_KELVIN] == 2700 assert state.attributes[ATTR_MAX_COLOR_TEMP_KELVIN] == 6500 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] + + +async def test_setup_non_color(hass: HomeAssistant, fritz: Mock) -> None: + """Test setup of platform of non color bulb.""" + device = FritzDeviceLightMock() + device.has_color = False + device.get_color_temps.return_value = [] + device.get_colors.return_value = {} + + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_BRIGHTNESS] == 100 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["brightness"] + + +async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> None: + """Test setup of platform of non color and non level bulb.""" + device = FritzDeviceLightMock() + device.has_color = False + device.has_level = False + device.get_color_temps.return_value = [] + device.get_colors.return_value = {} + + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_BRIGHTNESS] == 100 + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: @@ -80,6 +121,7 @@ async def test_setup_color(hass: HomeAssistant, fritz: Mock) -> None: assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" assert state.attributes[ATTR_BRIGHTNESS] == 100 assert state.attributes[ATTR_HS_COLOR] == (100, 70) + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"] async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: From 7232a0a786b6e598e85c43772259ef1f9ce09741 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Mar 2023 19:21:04 +0100 Subject: [PATCH 0325/1058] Add require_admin decorator to otbr WS API (#89385) * Add require_admin decorator to otbr WS API * Add require_admin decorator to forgotten otbr WS API --- .../components/otbr/websocket_api.py | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 7c69a8d0a2d9..497fc285d5f3 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -3,12 +3,7 @@ from typing import TYPE_CHECKING import python_otbr_api -from homeassistant.components.websocket_api import ( - ActiveConnection, - async_register_command, - async_response, - websocket_command, -) +from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -21,18 +16,19 @@ if TYPE_CHECKING: @callback def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" - async_register_command(hass, websocket_info) - async_register_command(hass, websocket_create_network) + websocket_api.async_register_command(hass, websocket_info) + websocket_api.async_register_command(hass, websocket_create_network) -@websocket_command( +@websocket_api.websocket_command( { "type": "otbr/info", } ) -@async_response +@websocket_api.require_admin +@websocket_api.async_response async def websocket_info( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Get OTBR info.""" if DOMAIN not in hass.data: @@ -56,14 +52,15 @@ async def websocket_info( ) -@websocket_command( +@websocket_api.websocket_command( { "type": "otbr/create_network", } ) -@async_response +@websocket_api.require_admin +@websocket_api.async_response async def websocket_create_network( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Create a new Thread network.""" if DOMAIN not in hass.data: From 84b5ea8ac0c14cb0c96448803f092eb2cb69e648 Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Wed, 8 Mar 2023 15:31:32 -0500 Subject: [PATCH 0326/1058] Bump SharkIQ to 1.0.2 (#89346) * SharkIQ Dep & Codeowner Update * Update code owners * Revert code owner changes --- homeassistant/components/sharkiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 1457f8f8a620..5b8656cefe2f 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==0.0.1"] + "requirements": ["sharkiq==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1821af222731..7c3e0c3f2aca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ sentry-sdk==1.16.0 sfrbox-api==0.0.6 # homeassistant.components.sharkiq -sharkiq==0.0.1 +sharkiq==1.0.2 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f74895e3c6d4..381737223795 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1658,7 +1658,7 @@ sentry-sdk==1.16.0 sfrbox-api==0.0.6 # homeassistant.components.sharkiq -sharkiq==0.0.1 +sharkiq==1.0.2 # homeassistant.components.sighthound simplehound==0.3 From cefba7c638badc3bb37e4f2db34c044c923edf41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 10:50:34 -1000 Subject: [PATCH 0327/1058] Avoid falling back to listening for all states when a template render raises an exception (#89392) When a template render raised an exception we would start listening for all states until the template did not raise an exception anymore. This was not needed since the entity that is causing the exception was already in the tracker. Re-rendering on all state changes can be extremely expensive and can bring an instance into a sluggish or unresponsive state when updating from a much older version that did not raise ValueError when a default was missing. --- homeassistant/helpers/event.py | 10 +++--- homeassistant/helpers/template.py | 2 ++ tests/helpers/test_event.py | 52 ++++++++++++++++++++++++++++++- tests/helpers/test_template.py | 11 +++++++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index edbb5fa73546..3ac715426e3e 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -838,6 +838,10 @@ class TrackTemplateResultInfo: self._track_state_changes: _TrackStateChangeFiltered | None = None self._time_listeners: dict[Template, Callable[[], None]] = {} + def __repr__(self) -> str: + """Return the representation.""" + return f"" + def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: """Activation of template tracking.""" block_render = False @@ -1651,12 +1655,6 @@ def _render_infos_needs_all_listener(render_infos: Iterable[RenderInfo]) -> bool if render_info.all_states or render_info.all_states_lifecycle: return True - # Previous call had an exception - # so we do not know which states - # to track - if render_info.exception: - return True - return False diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 2e112706fba8..c923bd2d84af 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -274,6 +274,8 @@ class RenderInfo: f" entities={self.entities}" f" rate_limit={self.rate_limit}" f" has_time={self.has_time}" + f" exception={self.exception}" + f" is_static={self.is_static}" ">" ) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index fb0925c15d2b..066460c90d88 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -2209,7 +2209,7 @@ async def test_track_template_result_errors( hass.states.async_set("switch.not_exist", "on") await hass.async_block_till_done() - assert len(syntax_error_runs) == 1 + assert len(syntax_error_runs) == 0 assert len(not_exist_runs) == 2 assert not_exist_runs[1][0].data.get("entity_id") == "switch.not_exist" assert not_exist_runs[1][1] == template_not_exist @@ -2229,6 +2229,56 @@ async def test_track_template_result_errors( assert isinstance(not_exist_runs[2][3], TemplateError) +async def test_track_template_result_transient_errors( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test tracking template with transient errors in the template.""" + hass.states.async_set("sensor.error", "unknown") + template_that_raises_sometimes = Template( + "{{ states('sensor.error') | float }}", hass + ) + + sometimes_error_runs = [] + + @ha.callback + def sometimes_error_listener(event, updates): + track_result = updates.pop() + sometimes_error_runs.append( + ( + event, + track_result.template, + track_result.last_result, + track_result.result, + ) + ) + + info = async_track_template_result( + hass, + [TrackTemplate(template_that_raises_sometimes, None)], + sometimes_error_listener, + ) + await hass.async_block_till_done() + + assert sometimes_error_runs == [] + assert "ValueError" in caplog.text + assert "ValueError" in repr(info) + caplog.clear() + + hass.states.async_set("sensor.error", "unavailable") + await hass.async_block_till_done() + assert len(sometimes_error_runs) == 1 + assert isinstance(sometimes_error_runs[0][3], TemplateError) + sometimes_error_runs.clear() + assert "ValueError" in repr(info) + + hass.states.async_set("sensor.error", "4") + await hass.async_block_till_done() + assert len(sometimes_error_runs) == 1 + assert sometimes_error_runs[0][3] == 4.0 + sometimes_error_runs.clear() + assert "ValueError" not in repr(info) + + async def test_static_string(hass: HomeAssistant) -> None: """Test a static string.""" template_refresh = Template("{{ 'static' }}", hass) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 2434b2aed152..f97f0a4b9c5f 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -4323,3 +4323,14 @@ def test_contains(hass: HomeAssistant, seq, value, expected) -> None: ) == expected ) + + +async def test_render_to_info_with_exception(hass: HomeAssistant) -> None: + """Test info is still available if the template has an exception.""" + hass.states.async_set("test_domain.object", "dog") + info = render_to_info(hass, '{{ states("test_domain.object") | float }}') + with pytest.raises(TemplateError, match="no default was specified"): + info.result() + + assert info.all_states is False + assert info.entities == {"test_domain.object"} From 5a499050f24b608bee08a9754e45fdac9d3ea54a Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 8 Mar 2023 21:52:01 +0100 Subject: [PATCH 0328/1058] Remove lingering timer related to camera (#89394) --- homeassistant/components/camera/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 11e75c50cfc6..e368779e9446 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -41,6 +41,7 @@ from homeassistant.const import ( CONF_FILENAME, CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STARTED, + EVENT_HOMEASSISTANT_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) @@ -378,7 +379,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity.async_update_token() entity.async_write_ha_state() - async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + unsub = async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + + @callback + def unsub_track_time_interval(_event: Event) -> None: + """Unsubscribe track time interval timer.""" + unsub() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unsub_track_time_interval) component.async_register_entity_service( SERVICE_ENABLE_MOTION, {}, "async_enable_motion_detection" From 09915f80477984cea47cd2397586d71158594d30 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Mar 2023 21:52:53 +0100 Subject: [PATCH 0329/1058] Add WS API for getting an OTBR's extended address (#89384) * Add WS API for getting an OTBR's extended address * Bump python-otbr-api to 1.0.8 * Really add require_admin decorator to otbr WS API --- homeassistant/components/otbr/__init__.py | 5 ++ homeassistant/components/otbr/manifest.json | 2 +- .../components/otbr/websocket_api.py | 27 +++++++ homeassistant/components/thread/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/otbr/test_websocket_api.py | 72 +++++++++++++++++++ 7 files changed, 108 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 78c5893c889b..ca977e774f39 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -78,6 +78,11 @@ class OTBRData: """Create an active operational dataset.""" return await self.api.create_active_dataset(dataset) + @_handle_otbr_error + async def get_extended_address(self) -> bytes: + """Get extended address (EUI-64).""" + return await self.api.get_extended_address() + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 0a6482b040ee..7efe5fefc3fd 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==1.0.5"] + "requirements": ["python-otbr-api==1.0.8"] } diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 497fc285d5f3..506a8cad1b79 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the OTBR Websocket API.""" websocket_api.async_register_command(hass, websocket_info) websocket_api.async_register_command(hass, websocket_create_network) + websocket_api.async_register_command(hass, websocket_get_extended_address) @websocket_api.websocket_command( @@ -96,3 +97,29 @@ async def websocket_create_network( return connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + "type": "otbr/get_extended_address", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_extended_address( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get extended address (EUI-64).""" + if DOMAIN not in hass.data: + connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") + return + + data: OTBRData = hass.data[DOMAIN] + + try: + extended_address = await data.get_extended_address() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "get_extended_address_failed", str(exc)) + return + + connection.send_result(msg["id"], {"extended_address": extended_address.hex()}) diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 547def834502..5fcb287796f7 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==1.0.5", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==1.0.8", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c3e0c3f2aca..bc91824850b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2097,7 +2097,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.5 +python-otbr-api==1.0.8 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 381737223795..d984049ca37a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1496,7 +1496,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.5 +python-otbr-api==1.0.8 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 789356574312..1c44091ae5d6 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -234,3 +234,75 @@ async def test_get_info_fetch_fails_3( assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "set_enabled_failed" + + +async def test_get_extended_address( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test get extended address.""" + + with patch( + "python_otbr_api.OTBR.get_extended_address", + return_value=bytes.fromhex("4EF6C4F3FF750626"), + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/get_extended_address", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == {"extended_address": "4EF6C4F3FF750626".lower()} + + +async def test_get_extended_address_no_entry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test get extended address.""" + await async_setup_component(hass, "otbr", {}) + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/get_extended_address", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + +async def test_get_extended_address_fetch_fails( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test get extended address.""" + await async_setup_component(hass, "otbr", {}) + + with patch( + "python_otbr_api.OTBR.get_extended_address", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/get_extended_address", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "get_extended_address_failed" From b07f614cf59e6c8a5238ff8e9375dc580e43b2d1 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Thu, 9 Mar 2023 07:53:12 +1100 Subject: [PATCH 0330/1058] Add loggers to gdacs manifest file (#89338) define loggers --- homeassistant/components/gdacs/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index 4db9d2fc8938..86904e3e9bc4 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/gdacs", "integration_type": "service", "iot_class": "cloud_polling", + "loggers": ["aio_georss_gdacs", "aio_georss_client"], "quality_scale": "platinum", "requirements": ["aio_georss_gdacs==0.8"] } From 5dbab21f9aa57d5c541b2e03f677b109adabd607 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 10:53:48 -1000 Subject: [PATCH 0331/1058] Fix missing f-string in filterable_job (#89340) * Fix missing f-string in filterable_job * remove bad test --- homeassistant/core.py | 2 +- tests/components/homematicip_cloud/test_init.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index e8fb41be32dd..bfccb721d8d1 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1118,7 +1118,7 @@ class EventBus: ) filterable_job = _FilterableJob( - HassJob(_onetime_listener, "onetime listen {event_type} {listener}"), + HassJob(_onetime_listener, f"onetime listen {event_type} {listener}"), None, False, ) diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py index 7f293b387635..5b9472d329b4 100644 --- a/tests/components/homematicip_cloud/test_init.py +++ b/tests/components/homematicip_cloud/test_init.py @@ -157,7 +157,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert config_entries[0].state is ConfigEntryState.LOADED await hass.config_entries.async_unload(config_entries[0].entry_id) assert config_entries[0].state is ConfigEntryState.NOT_LOADED - assert mock_hap.return_value.mock_calls[2][0] == "async_reset" # entry is unloaded assert hass.data[HMIPC_DOMAIN] == {} From 4f11344bc3b9e6bf4c159e7cbfedfdd078b539ce Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Wed, 8 Mar 2023 15:56:40 -0500 Subject: [PATCH 0332/1058] Bump pymazda to 0.3.8 (#89387) --- homeassistant/components/mazda/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json index 64bb8bef0c08..2c2aafa960e9 100644 --- a/homeassistant/components/mazda/manifest.json +++ b/homeassistant/components/mazda/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pymazda"], "quality_scale": "platinum", - "requirements": ["pymazda==0.3.7"] + "requirements": ["pymazda==0.3.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index bc91824850b5..b25b637d68a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1771,7 +1771,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.3.7 +pymazda==0.3.8 # homeassistant.components.mediaroom pymediaroom==0.6.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d984049ca37a..2b2fcf56f2e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1275,7 +1275,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.3.7 +pymazda==0.3.8 # homeassistant.components.melcloud pymelcloud==2.5.8 From e1d62b554a00482f8d43a5c2d4c318a8a16adcab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 11:01:47 -1000 Subject: [PATCH 0333/1058] Migrate integration_platform helper to use async_get_integrations (#89303) * Migrate integration_platform helper to use async_get_integrations We were fetching integrations inside the gather one at a time. This is inefficent. * cleanup * cleanup * add task name * small tweaks * gather only if we have tasks --- homeassistant/helpers/integration_platform.py | 80 +++++++++++++------ tests/helpers/test_integration_platform.py | 26 ++++++ 2 files changed, 82 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 9255824cddfd..ef05dae518bc 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -8,8 +8,8 @@ import logging from typing import Any from homeassistant.const import EVENT_COMPONENT_LOADED -from homeassistant.core import Event, HomeAssistant -from homeassistant.loader import async_get_integration, bind_hass +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.loader import Integration, async_get_integrations, bind_hass from homeassistant.setup import ATTR_COMPONENT _LOGGER = logging.getLogger(__name__) @@ -26,14 +26,24 @@ class IntegrationPlatform: async def _async_process_single_integration_platform_component( - hass: HomeAssistant, component_name: str, integration_platform: IntegrationPlatform + hass: HomeAssistant, + component_name: str, + integration: Integration | Exception, + integration_platform: IntegrationPlatform, ) -> None: """Process a single integration platform.""" if component_name in integration_platform.seen_components: return integration_platform.seen_components.add(component_name) - integration = await async_get_integration(hass, component_name) + if isinstance(integration, Exception): + _LOGGER.exception( + "Error importing integration %s for %s", + component_name, + integration_platform.platform_name, + ) + return + platform_name = integration_platform.platform_name try: @@ -75,14 +85,22 @@ async def async_process_integration_platform_for_component( integration_platforms: list[IntegrationPlatform] = hass.data[ DATA_INTEGRATION_PLATFORMS ] - await asyncio.gather( - *[ + integrations = await async_get_integrations(hass, (component_name,)) + tasks = [ + asyncio.create_task( _async_process_single_integration_platform_component( - hass, component_name, integration_platform - ) - for integration_platform in integration_platforms - ] - ) + hass, + component_name, + integrations[component_name], + integration_platform, + ), + name=f"process integration platform {integration_platform.platform_name} for {component_name}", + ) + for integration_platform in integration_platforms + if component_name not in integration_platform.seen_components + ] + if tasks: + await asyncio.gather(*tasks) @bind_hass @@ -98,25 +116,39 @@ async def async_process_integration_platforms( async def _async_component_loaded(event: Event) -> None: """Handle a new component loaded.""" - comp = event.data[ATTR_COMPONENT] - if "." not in comp: - await async_process_integration_platform_for_component(hass, comp) + await async_process_integration_platform_for_component( + hass, event.data[ATTR_COMPONENT] + ) - hass.bus.async_listen(EVENT_COMPONENT_LOADED, _async_component_loaded) + @callback + def _async_component_loaded_filter(event: Event) -> bool: + """Handle integration platforms loaded.""" + return "." not in event.data[ATTR_COMPONENT] + + hass.bus.async_listen( + EVENT_COMPONENT_LOADED, + _async_component_loaded, + event_filter=_async_component_loaded_filter, + ) integration_platforms: list[IntegrationPlatform] = hass.data[ DATA_INTEGRATION_PLATFORMS ] integration_platform = IntegrationPlatform(platform_name, process_platform, set()) integration_platforms.append(integration_platform) - if top_level_components := ( + if top_level_components := [ comp for comp in hass.config.components if "." not in comp - ): - await asyncio.gather( - *[ + ]: + integrations = await async_get_integrations(hass, top_level_components) + tasks = [ + asyncio.create_task( _async_process_single_integration_platform_component( - hass, comp, integration_platform - ) - for comp in top_level_components - ] - ) + hass, comp, integrations[comp], integration_platform + ), + name=f"process integration platform {platform_name} for {comp}", + ) + for comp in top_level_components + if comp not in integration_platform.seen_components + ] + if tasks: + await asyncio.gather(*tasks) diff --git a/tests/helpers/test_integration_platform.py b/tests/helpers/test_integration_platform.py index 5848c6f9541e..2dfc0742e267 100644 --- a/tests/helpers/test_integration_platform.py +++ b/tests/helpers/test_integration_platform.py @@ -1,6 +1,8 @@ """Test integration platform helpers.""" from unittest.mock import Mock +import pytest + from homeassistant.core import HomeAssistant from homeassistant.helpers.integration_platform import ( async_process_integration_platform_for_component, @@ -51,3 +53,27 @@ async def test_process_integration_platforms_none_loaded(hass: HomeAssistant) -> # Verify we can call async_process_integration_platform_for_component # when there are none loaded and it does not throw await async_process_integration_platform_for_component(hass, "any") + + +async def test_broken_integration( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling an integration with a broken or missing manifest.""" + Mock() + hass.config.components.add("loaded") + + event_platform = Mock() + mock_platform(hass, "event.platform_to_check", event_platform) + + processed = [] + + async def _process_platform(hass, domain, platform): + """Process platform.""" + processed.append((domain, platform)) + + await async_process_integration_platforms( + hass, "platform_to_check", _process_platform + ) + + assert len(processed) == 0 + assert "Error importing integration loaded for platform_to_check" in caplog.text From 5c768c3f89d65d86a71780a9543a890191d5183c Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 8 Mar 2023 16:02:18 -0500 Subject: [PATCH 0334/1058] Bump aiosomecomfort to 0.0.14 (#89393) --- homeassistant/components/honeywell/climate.py | 23 +++++++++---------- .../components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 9184b8c3d667..e9dae1e20745 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -292,21 +292,22 @@ class HoneywellUSThermostat(ClimateEntity): hour_cool, minute_cool = divmod( self._device.raw_ui_data["CoolNextPeriod"] * 15, 60 ) - # Set hold time + # Set temporary hold time and temperature if mode in COOLING_MODES: await self._device.set_hold_cool( - datetime.time(hour_cool, minute_cool) + datetime.time(hour_cool, minute_cool), temperature ) if mode in HEATING_MODES: await self._device.set_hold_heat( - datetime.time(hour_heat, minute_heat) + datetime.time(hour_heat, minute_heat), temperature ) - # Set temperature if not in auto - if mode == "cool": - await self._device.set_setpoint_cool(temperature) - if mode == "heat": - await self._device.set_setpoint_heat(temperature) + # Set temperature if not in auto - set the temperature + else: + if mode == "cool": + await self._device.set_setpoint_cool(temperature) + if mode == "heat": + await self._device.set_setpoint_heat(temperature) except aiosomecomfort.SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) @@ -350,11 +351,9 @@ class HoneywellUSThermostat(ClimateEntity): # Set permanent hold # and Set temperature if mode in COOLING_MODES: - await self._device.set_hold_cool(True) - await self._device.set_setpoint_cool(self._cool_away_temp) + await self._device.set_hold_cool(True, self._cool_away_temp) if mode in HEATING_MODES: - await self._device.set_hold_heat(True) - await self._device.set_setpoint_heat(self._heat_away_temp) + await self._device.set_hold_heat(True, self._heat_away_temp) except aiosomecomfort.SomeComfortError: _LOGGER.error( diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 989e60574900..8f3b66ddeacb 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.11"] + "requirements": ["aiosomecomfort==0.0.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index b25b637d68a0..f0b72b981ed1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.11 +aiosomecomfort==0.0.14 # homeassistant.components.steamist aiosteamist==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b2fcf56f2e0..fa524b6aa8fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -254,7 +254,7 @@ aioskybell==22.7.0 aioslimproto==2.1.1 # homeassistant.components.honeywell -aiosomecomfort==0.0.11 +aiosomecomfort==0.0.14 # homeassistant.components.steamist aiosteamist==0.3.2 From 8af37f7fee7930f0ab7cd905952288b7cea10873 Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Wed, 8 Mar 2023 16:05:23 -0500 Subject: [PATCH 0335/1058] Update SharkIQ code owners (#89388) --- CODEOWNERS | 4 ++-- homeassistant/components/sharkiq/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 46d78113abbc..9020d229fe90 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1056,8 +1056,8 @@ build.json @home-assistant/supervisor /homeassistant/components/seven_segments/ @fabaff /homeassistant/components/sfr_box/ @epenet /tests/components/sfr_box/ @epenet -/homeassistant/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10 -/tests/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10 +/homeassistant/components/sharkiq/ @JeffResc @funkybunch +/tests/components/sharkiq/ @JeffResc @funkybunch /homeassistant/components/shell_command/ @home-assistant/core /tests/components/shell_command/ @home-assistant/core /homeassistant/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 5b8656cefe2f..0e07dd969023 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -1,7 +1,7 @@ { "domain": "sharkiq", "name": "Shark IQ", - "codeowners": ["@JeffResc", "@funkybunch", "@AritroSaha10"], + "codeowners": ["@JeffResc", "@funkybunch"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", From 366baef7f629d4216b02ba9d34b51692d3967c9b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 8 Mar 2023 22:35:06 +0100 Subject: [PATCH 0336/1058] Allow enum as MQTT sensor device_class (#89391) --- homeassistant/components/mqtt/sensor.py | 2 +- tests/components/mqtt/test_sensor.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 934f73695803..aea357bea623 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -281,7 +281,7 @@ class MqttSensor(MqttEntity, RestoreSensor): else: self._attr_native_value = new_value return - if self.device_class is None: + if self.device_class in {None, SensorDeviceClass.ENUM}: self._attr_native_value = new_value return try: diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 66836a16ee1b..3112564cb8a2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -141,6 +141,8 @@ async def test_setting_sensor_value_via_mqtt_message( True, ), (sensor.SensorDeviceClass.TIMESTAMP, "invalid", STATE_UNKNOWN, True), + (sensor.SensorDeviceClass.ENUM, "some_value", "some_value", False), + (None, "some_value", "some_value", False), ], ) async def test_setting_sensor_native_value_handling_via_mqtt_message( From 0d948a0f114e2a497290d24621e7faffdb09f0e5 Mon Sep 17 00:00:00 2001 From: Dillon Fearns Date: Wed, 8 Mar 2023 21:39:33 +0000 Subject: [PATCH 0337/1058] Bump roombapy to 1.6.6 (#89366) Co-authored-by: J. Nick Koston --- 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 5aa630df5d05..08815cae9fb2 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.5"] + "requirements": ["roombapy==1.6.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index f0b72b981ed1..9bbcccb1ac64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2264,7 +2264,7 @@ rocketchat-API==0.6.1 rokuecp==0.17.1 # homeassistant.components.roomba -roombapy==1.6.5 +roombapy==1.6.6 # homeassistant.components.roon roonapi==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa524b6aa8fe..cbc7961352fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1606,7 +1606,7 @@ ring_doorbell==0.7.2 rokuecp==0.17.1 # homeassistant.components.roomba -roombapy==1.6.5 +roombapy==1.6.6 # homeassistant.components.roon roonapi==0.1.4 From bfae8992a931e50103473924ce4fb2f3af2fab6d Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 8 Mar 2023 10:42:07 -1100 Subject: [PATCH 0338/1058] Better log message for KNX expose conversion error (#89400) --- homeassistant/components/knx/expose.py | 10 ++++++++-- tests/components/knx/test_expose.py | 9 +++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 05e367faeec7..308fc4eacd17 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -161,8 +161,14 @@ class KNXExposeSensor: """Set new value on xknx ExposeSensor.""" try: await self.device.set(value) - except ConversionError: - _LOGGER.exception("Error during sending of expose sensor value") + except ConversionError as err: + _LOGGER.warning( + 'Could not expose %s %s value "%s" to KNX: %s', + self.entity_id, + self.expose_attribute or "state", + value, + err, + ) class KNXExposeTime: diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 1fca793b8caf..9bb6f22470a5 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -3,6 +3,8 @@ from datetime import timedelta import time from unittest.mock import patch +import pytest + from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_TYPE @@ -201,7 +203,7 @@ async def test_expose_cooldown(hass: HomeAssistant, knx: KNXTestKit) -> None: async def test_expose_conversion_exception( - hass: HomeAssistant, knx: KNXTestKit + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, knx: KNXTestKit ) -> None: """Test expose throws exception.""" @@ -230,8 +232,11 @@ async def test_expose_conversion_exception( "on", {attribute: 101}, ) - await knx.assert_no_telegram() + assert ( + 'Could not expose fake.entity fake_attribute value "101.0" to KNX:' + in caplog.text + ) @patch("time.localtime") From 386533a16f34224e7cb90aa01645e2e8401a6481 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 8 Mar 2023 22:57:54 +0100 Subject: [PATCH 0339/1058] Update mypy to 1.1.1 (#89268) * Update mypy to 1.1.1 * Update pydantic to 1.10.6 --- homeassistant/block_async_io.py | 2 +- homeassistant/components/http/__init__.py | 2 +- homeassistant/components/matter/models.py | 8 ++++++-- homeassistant/components/p1_monitor/diagnostics.py | 9 +++++++-- homeassistant/components/zeroconf/usage.py | 2 +- homeassistant/components/zwave_js/discovery.py | 7 +++++-- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/helpers/httpx_client.py | 2 +- homeassistant/helpers/schema_config_entry_flow.py | 2 +- homeassistant/runner.py | 2 +- requirements_test.txt | 4 ++-- 11 files changed, 27 insertions(+), 15 deletions(-) diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index 753fda5ae9be..d7c1a7c9eea2 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -8,7 +8,7 @@ from .util.async_ import protect_loop def enable() -> None: """Enable the detection of blocking calls in the event loop.""" # Prevent urllib3 and requests doing I/O in event loop - HTTPConnection.putrequest = protect_loop( # type: ignore[assignment] + HTTPConnection.putrequest = protect_loop( # type: ignore[method-assign] HTTPConnection.putrequest ) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1c201725c003..04b94dc3b819 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -460,7 +460,7 @@ class HomeAssistantHTTP: # This will now raise a RunTimeError. # To work around this we now prevent the router from getting frozen # pylint: disable-next=protected-access - self.app._router.freeze = lambda: None # type: ignore[assignment] + self.app._router.freeze = lambda: None # type: ignore[method-assign] self.runner = web.AppRunner(self.app) await self.runner.setup() diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 3ce5f1846728..2575b16e8b16 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -1,8 +1,9 @@ """Models used for the Matter integration.""" +from __future__ import annotations from collections.abc import Callable from dataclasses import asdict, dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -12,11 +13,14 @@ from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform from homeassistant.helpers.entity import EntityDescription +if TYPE_CHECKING: + from _typeshed import DataclassInstance + class DataclassMustHaveAtLeastOne: """A dataclass that must have at least one input parameter that is not None.""" - def __post_init__(self) -> None: + def __post_init__(self: DataclassInstance) -> None: """Post dataclass initialization.""" if all(val is None for val in asdict(self).values()): raise ValueError("At least one input parameter must not be None") diff --git a/homeassistant/components/p1_monitor/diagnostics.py b/homeassistant/components/p1_monitor/diagnostics.py index 29f48d47cd99..b2668f060a44 100644 --- a/homeassistant/components/p1_monitor/diagnostics.py +++ b/homeassistant/components/p1_monitor/diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import asdict -from typing import Any +from typing import TYPE_CHECKING, Any, cast from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry @@ -18,6 +18,9 @@ from .const import ( SERVICE_WATERMETER, ) +if TYPE_CHECKING: + from _typeshed import DataclassInstance + TO_REDACT = { CONF_HOST, } @@ -42,6 +45,8 @@ async def async_get_config_entry_diagnostics( } if coordinator.has_water_meter: - data["data"]["watermeter"] = asdict(coordinator.data[SERVICE_WATERMETER]) + data["data"]["watermeter"] = asdict( + cast("DataclassInstance", coordinator.data[SERVICE_WATERMETER]) + ) return data diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py index 0c452149bfd0..b9d51cd3c367 100644 --- a/homeassistant/components/zeroconf/usage.py +++ b/homeassistant/components/zeroconf/usage.py @@ -31,4 +31,4 @@ def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None: return zeroconf.Zeroconf.__new__ = new_zeroconf_new # type: ignore[assignment] - zeroconf.Zeroconf.__init__ = new_zeroconf_init # type: ignore[assignment] + zeroconf.Zeroconf.__init__ = new_zeroconf_init # type: ignore[method-assign] diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 5dfab3077e49..36295a645589 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import asdict, dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from awesomeversion import AwesomeVersion from zwave_js_server.const import ( @@ -60,6 +60,9 @@ from .discovery_data_template import ( ) from .helpers import ZwaveValueID +if TYPE_CHECKING: + from _typeshed import DataclassInstance + class ValueType(StrEnum): """Enum with all value types.""" @@ -73,7 +76,7 @@ class ValueType(StrEnum): class DataclassMustHaveAtLeastOne: """A dataclass that must have at least one input parameter that is not None.""" - def __post_init__(self) -> None: + def __post_init__(self: DataclassInstance) -> None: """Post dataclass initialization.""" if all(val is None for val in asdict(self).values()): raise ValueError("At least one input parameter must not be None") diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index d623de5e816a..f75b8e3aa400 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -129,7 +129,7 @@ def _async_create_clientsession( {USER_AGENT: SERVER_SOFTWARE}, ) - clientsession.close = warn_use( # type: ignore[assignment] + clientsession.close = warn_use( # type: ignore[method-assign] clientsession.close, WARN_CLOSE_MSG, ) diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index e02759b09f8f..2475469a7d10 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -72,7 +72,7 @@ def create_async_httpx_client( original_aclose = client.aclose - client.aclose = warn_use( # type: ignore[assignment] + client.aclose = warn_use( # type: ignore[method-assign] client.aclose, "closes the Home Assistant httpx client" ) diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 9f76a639e0fc..5101e5c69a78 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -275,7 +275,7 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): ) # Create an async_get_options_flow method - cls.async_get_options_flow = _async_get_options_flow # type: ignore[assignment] + cls.async_get_options_flow = _async_get_options_flow # type: ignore[method-assign] # Create flow step methods for each step defined in the flow schema for step in cls.config_flow: diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 0926fb67459e..e5a87a4b092b 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -110,7 +110,7 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): thread_name_prefix="SyncWorker", max_workers=MAX_EXECUTOR_WORKERS ) loop.set_default_executor(executor) - loop.set_default_executor = warn_use( # type: ignore[assignment] + loop.set_default_executor = warn_use( # type: ignore[method-assign] loop.set_default_executor, "sets default executor on the event loop" ) return loop diff --git a/requirements_test.txt b/requirements_test.txt index f5be4d075972..10ceb81365de 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,9 +12,9 @@ codecov==2.1.12 coverage==7.2.1 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.0.1 +mypy==1.1.1 pre-commit==3.1.0 -pydantic==1.10.5 +pydantic==1.10.6 pylint==2.17.0 pylint-per-file-ignores==1.1.0 pipdeptree==2.5.0 From 170a13302c8fdf2285615126bbf80b946a9a666b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 14:51:45 -1000 Subject: [PATCH 0340/1058] Reduce overhead to store context ids in the database (#88942) --- homeassistant/components/logbook/models.py | 58 +++++-- homeassistant/components/logbook/processor.py | 28 ++-- .../components/logbook/queries/__init__.py | 4 +- .../components/logbook/queries/all.py | 16 +- .../components/logbook/queries/common.py | 30 ++-- .../components/logbook/queries/devices.py | 6 +- .../components/logbook/queries/entities.py | 12 +- .../logbook/queries/entities_and_devices.py | 12 +- homeassistant/components/recorder/core.py | 6 + .../components/recorder/db_schema.py | 84 +++++++--- .../components/recorder/migration.py | 155 +++++++++++++---- homeassistant/components/recorder/models.py | 38 +++++ homeassistant/components/recorder/queries.py | 28 ++++ homeassistant/components/recorder/tasks.py | 17 ++ homeassistant/util/ulid.py | 4 +- tests/components/logbook/common.py | 16 +- tests/components/logbook/test_init.py | 60 +++---- .../components/logbook/test_websocket_api.py | 18 +- .../db_schema_23_with_newer_columns.py | 35 ++++ tests/components/recorder/db_schema_30.py | 34 ++++ tests/components/recorder/test_migrate.py | 157 +++++++++++++++++- tests/conftest.py | 18 ++ 22 files changed, 676 insertions(+), 160 deletions(-) diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 3fc4b5dac8b4..a5ce9eddcec2 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -2,14 +2,21 @@ from __future__ import annotations from dataclasses import dataclass -import json from typing import Any, cast from sqlalchemy.engine.row import Row +from homeassistant.components.recorder.models import ( + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, +) 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 class LazyEventPartialState: @@ -22,9 +29,9 @@ class LazyEventPartialState: "event_type", "entity_id", "state", - "context_id", - "context_user_id", - "context_parent_id", + "context_id_bin", + "context_user_id_bin", + "context_parent_id_bin", "data", ] @@ -40,9 +47,9 @@ class LazyEventPartialState: self.event_type: str | None = self.row.event_type self.entity_id: str | None = self.row.entity_id self.state = self.row.state - self.context_id: str | None = self.row.context_id - self.context_user_id: str | None = self.row.context_user_id - self.context_parent_id: str | None = self.row.context_parent_id + self.context_id_bin: bytes | None = self.row.context_id_bin + self.context_user_id_bin: bytes | None = self.row.context_user_id_bin + self.context_parent_id_bin: bytes | None = self.row.context_parent_id_bin if data := getattr(row, "data", None): # If its an EventAsRow we can avoid the whole # json decode process as we already have the data @@ -55,9 +62,24 @@ class LazyEventPartialState: self.data = event_data else: self.data = self._event_data_cache[source] = cast( - dict[str, Any], json.loads(source) + dict[str, Any], json_loads(source) ) + @property + def context_id(self) -> str | None: + """Return the context id.""" + return bytes_to_ulid_or_none(self.context_id_bin) + + @property + def context_user_id(self) -> str | None: + """Return the context user id.""" + return bytes_to_uuid_hex_or_none(self.context_user_id_bin) + + @property + def context_parent_id(self) -> str | None: + """Return the context parent id.""" + return bytes_to_ulid_or_none(self.context_parent_id_bin) + @dataclass(frozen=True) class EventAsRow: @@ -65,7 +87,7 @@ class EventAsRow: data: dict[str, Any] context: Context - context_id: str + context_id_bin: bytes time_fired_ts: float state_id: int event_data: str | None = None @@ -73,8 +95,8 @@ class EventAsRow: event_id: None = None entity_id: str | None = None icon: str | None = None - context_user_id: str | None = None - context_parent_id: str | None = None + context_user_id_bin: bytes | None = None + context_parent_id_bin: bytes | None = None event_type: str | None = None state: str | None = None shared_data: str | None = None @@ -85,13 +107,14 @@ class EventAsRow: def async_event_to_row(event: Event) -> EventAsRow: """Convert an event to a row.""" if event.event_type != EVENT_STATE_CHANGED: + context = event.context return EventAsRow( data=event.data, context=event.context, event_type=event.event_type, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, + 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), state_id=hash(event), ) @@ -99,14 +122,15 @@ def async_event_to_row(event: Event) -> EventAsRow: # that are missing new_state or old_state # since the logbook does not show these new_state: State = event.data["new_state"] + context = new_state.context return EventAsRow( data=event.data, context=event.context, entity_id=new_state.entity_id, state=new_state.state, - context_id=new_state.context.id, - context_user_id=new_state.context.user_id, - context_parent_id=new_state.context.parent_id, + 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), state_id=hash(event), icon=new_state.attributes.get(ATTR_ICON), diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 289ee677a21e..39d8e920aa4a 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -12,6 +12,7 @@ from sqlalchemy.engine.row import Row from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( + bytes_to_uuid_hex_or_none, process_datetime_to_timestamp, process_timestamp_to_utc_isoformat, ) @@ -261,14 +262,14 @@ class ContextLookup: """Memorize context origin.""" self.hass = hass self._memorize_new = True - self._lookup: dict[str | None, Row | EventAsRow | None] = {None: None} + self._lookup: dict[bytes | None, Row | EventAsRow | None] = {None: None} - def memorize(self, row: Row | EventAsRow) -> str | None: + def memorize(self, row: Row | EventAsRow) -> bytes | None: """Memorize a context from the database.""" if self._memorize_new: - context_id: str = row.context_id - self._lookup.setdefault(context_id, row) - return context_id + context_id_bin: bytes = row.context_id_bin + self._lookup.setdefault(context_id_bin, row) + return context_id_bin return None def clear(self) -> None: @@ -276,9 +277,9 @@ class ContextLookup: self._lookup.clear() self._memorize_new = False - def get(self, context_id: str) -> Row | EventAsRow | None: + def get(self, context_id_bin: bytes) -> Row | EventAsRow | None: """Get the context origin.""" - return self._lookup.get(context_id) + return self._lookup.get(context_id_bin) class ContextAugmenter: @@ -293,7 +294,7 @@ class ContextAugmenter: self.include_entity_name = logbook_run.include_entity_name def _get_context_row( - self, context_id: str | None, row: Row | EventAsRow + self, context_id: bytes | None, row: Row | EventAsRow ) -> Row | EventAsRow | None: """Get the context row from the id or row context.""" if context_id: @@ -305,11 +306,11 @@ class ContextAugmenter: return None def augment( - self, data: dict[str, Any], row: Row | EventAsRow, context_id: str | None + self, data: dict[str, Any], row: Row | EventAsRow, context_id: bytes | None ) -> None: """Augment data from the row and cache.""" - if context_user_id := row.context_user_id: - data[CONTEXT_USER_ID] = context_user_id + if context_user_id_bin := row.context_user_id_bin: + data[CONTEXT_USER_ID] = bytes_to_uuid_hex_or_none(context_user_id_bin) if not (context_row := self._get_context_row(context_id, row)): return @@ -317,11 +318,12 @@ class ContextAugmenter: if _rows_match(row, context_row): # This is the first event with the given ID. Was it directly caused by # a parent event? + context_parent_id_bin = row.context_parent_id_bin if ( - not row.context_parent_id + not context_parent_id_bin or ( context_row := self._get_context_row( - row.context_parent_id, context_row + context_parent_id_bin, context_row ) ) is None diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 8a2ee40de4f2..b88fd4842cd0 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -6,6 +6,7 @@ from datetime import datetime as dt from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components.recorder.filters import Filters +from homeassistant.components.recorder.models import ulid_to_bytes_or_none from homeassistant.helpers.json import json_dumps from homeassistant.util import dt as dt_util @@ -27,6 +28,7 @@ def statement_for_request( """Generate the logbook statement for a logbook request.""" start_day = dt_util.utc_to_timestamp(start_day_dt) end_day = dt_util.utc_to_timestamp(end_day_dt) + context_id_bin = ulid_to_bytes_or_none(context_id) # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: @@ -38,7 +40,7 @@ def statement_for_request( event_types, states_entity_filter, events_entity_filter, - context_id, + context_id_bin, ) # sqlalchemy caches object quoting, the diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 729a4d2195a2..5311d5a9d6e0 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -26,28 +26,28 @@ def all_stmt( event_types: tuple[str, ...], states_entity_filter: ColumnElement | None = None, events_entity_filter: ColumnElement | None = None, - context_id: str | None = None, + context_id_bin: bytes | None = None, ) -> StatementLambdaElement: """Generate a logbook query for all entities.""" stmt = lambda_stmt( lambda: select_events_without_states(start_day, end_day, event_types) ) - if context_id is not None: + if context_id_bin is not None: # Once all the old `state_changed` events # are gone from the database remove the # _legacy_select_events_context_id() - stmt += lambda s: s.where(Events.context_id == context_id).union_all( + stmt += lambda s: s.where(Events.context_id_bin == context_id_bin).union_all( _states_query_for_context_id( start_day, end_day, # https://github.com/python/mypy/issues/2608 - context_id, # type:ignore[arg-type] + context_id_bin, # type:ignore[arg-type] ), legacy_select_events_context_id( start_day, end_day, # https://github.com/python/mypy/issues/2608 - context_id, # type:ignore[arg-type] + context_id_bin, # type:ignore[arg-type] ), ) else: @@ -76,12 +76,14 @@ def _apply_all_hints(sel: Select) -> Select: """Force mysql to use the right index on large selects.""" return sel.with_hint( States, f"FORCE INDEX ({LAST_UPDATED_INDEX_TS})", dialect_name="mysql" + ).with_hint( + States, f"FORCE INDEX ({LAST_UPDATED_INDEX_TS})", dialect_name="mariadb" ) def _states_query_for_context_id( - start_day: float, end_day: float, context_id: str + start_day: float, end_day: float, context_id_bin: bytes ) -> Select: return apply_states_filters(select_states(), start_day, end_day).where( - States.context_id == context_id + States.context_id_bin == context_id_bin ) diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index ca00f31615a8..a0c8ddbdda20 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -10,11 +10,11 @@ from sqlalchemy.sql.expression import literal from sqlalchemy.sql.selectable import Select from homeassistant.components.recorder.db_schema import ( - EVENTS_CONTEXT_ID_INDEX, + EVENTS_CONTEXT_ID_BIN_INDEX, OLD_FORMAT_ATTRS_JSON, OLD_STATE, SHARED_ATTRS_JSON, - STATES_CONTEXT_ID_INDEX, + STATES_CONTEXT_ID_BIN_INDEX, EventData, Events, StateAttributes, @@ -47,9 +47,9 @@ EVENT_COLUMNS = ( Events.event_type.label("event_type"), Events.event_data.label("event_data"), Events.time_fired_ts.label("time_fired_ts"), - Events.context_id.label("context_id"), - Events.context_user_id.label("context_user_id"), - Events.context_parent_id.label("context_parent_id"), + Events.context_id_bin.label("context_id_bin"), + Events.context_user_id_bin.label("context_user_id_bin"), + Events.context_parent_id_bin.label("context_parent_id_bin"), ) STATE_COLUMNS = ( @@ -79,9 +79,9 @@ EVENT_COLUMNS_FOR_STATE_SELECT = ( ), literal(value=None, type_=sqlalchemy.Text).label("event_data"), States.last_updated_ts.label("time_fired_ts"), - States.context_id.label("context_id"), - States.context_user_id.label("context_user_id"), - States.context_parent_id.label("context_parent_id"), + States.context_id_bin.label("context_id_bin"), + States.context_user_id_bin.label("context_user_id_bin"), + States.context_parent_id_bin.label("context_parent_id_bin"), literal(value=None, type_=sqlalchemy.Text).label("shared_data"), ) @@ -113,7 +113,7 @@ def select_events_context_id_subquery( ) -> Select: """Generate the select for a context_id subquery.""" return ( - select(Events.context_id) + select(Events.context_id_bin) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) .where(Events.event_type.in_(event_types)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) @@ -162,7 +162,7 @@ def select_states() -> Select: def legacy_select_events_context_id( - start_day: float, end_day: float, context_id: str + start_day: float, end_day: float, context_id_bin: bytes ) -> Select: """Generate a legacy events context id select that also joins states.""" # This can be removed once we no longer have event_ids in the states table @@ -183,7 +183,7 @@ def legacy_select_events_context_id( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.context_id == context_id) + .where(Events.context_id_bin == context_id_bin) ) @@ -277,12 +277,16 @@ def _not_uom_attributes_matcher() -> BooleanClauseList: def apply_states_context_hints(sel: Select) -> Select: """Force mysql to use the right index on large context_id selects.""" return sel.with_hint( - States, f"FORCE INDEX ({STATES_CONTEXT_ID_INDEX})", dialect_name="mysql" + States, f"FORCE INDEX ({STATES_CONTEXT_ID_BIN_INDEX})", dialect_name="mysql" + ).with_hint( + States, f"FORCE INDEX ({STATES_CONTEXT_ID_BIN_INDEX})", dialect_name="mariadb" ) def apply_events_context_hints(sel: Select) -> Select: """Force mysql to use the right index on large context_id selects.""" return sel.with_hint( - Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_INDEX})", dialect_name="mysql" + Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_BIN_INDEX})", dialect_name="mysql" + ).with_hint( + Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_BIN_INDEX})", dialect_name="mariadb" ) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index fa2deaf4c020..303313602df9 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -36,7 +36,7 @@ def _select_device_id_context_ids_sub_query( inner = select_events_context_id_subquery(start_day, end_day, event_types).where( apply_event_device_id_matchers(json_quotable_device_ids) ) - return select(inner.c.context_id).group_by(inner.c.context_id) + return select(inner.c.context_id_bin).group_by(inner.c.context_id_bin) def _apply_devices_context_union( @@ -57,12 +57,12 @@ def _apply_devices_context_union( apply_events_context_hints( select_events_context_only() .select_from(devices_cte) - .outerjoin(Events, devices_cte.c.context_id == Events.context_id) + .outerjoin(Events, devices_cte.c.context_id_bin == Events.context_id_bin) ).outerjoin(EventData, (Events.data_id == EventData.data_id)), apply_states_context_hints( select_states_context_only() .select_from(devices_cte) - .outerjoin(States, devices_cte.c.context_id == States.context_id) + .outerjoin(States, devices_cte.c.context_id_bin == States.context_id_bin) ), ) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 3d26443ce90a..2c095d1b0519 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -42,13 +42,13 @@ def _select_entities_context_ids_sub_query( select_events_context_id_subquery(start_day, end_day, event_types).where( apply_event_entity_id_matchers(json_quoted_entity_ids) ), - apply_entities_hints(select(States.context_id)) + apply_entities_hints(select(States.context_id_bin)) .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) .where(States.entity_id.in_(entity_ids)), ) - return select(union.c.context_id).group_by(union.c.context_id) + return select(union.c.context_id_bin).group_by(union.c.context_id_bin) def _apply_entities_context_union( @@ -77,12 +77,12 @@ def _apply_entities_context_union( apply_events_context_hints( select_events_context_only() .select_from(entities_cte) - .outerjoin(Events, entities_cte.c.context_id == Events.context_id) + .outerjoin(Events, entities_cte.c.context_id_bin == Events.context_id_bin) ).outerjoin(EventData, (Events.data_id == EventData.data_id)), apply_states_context_hints( select_states_context_only() .select_from(entities_cte) - .outerjoin(States, entities_cte.c.context_id == States.context_id) + .outerjoin(States, entities_cte.c.context_id_bin == States.context_id_bin) ), ) @@ -138,4 +138,8 @@ def apply_entities_hints(sel: Select) -> Select: """Force mysql to use the right index on large selects.""" return sel.with_hint( States, f"FORCE INDEX ({ENTITY_ID_LAST_UPDATED_INDEX_TS})", dialect_name="mysql" + ).with_hint( + States, + f"FORCE INDEX ({ENTITY_ID_LAST_UPDATED_INDEX_TS})", + dialect_name="mariadb", ) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 43d11d0bdff0..ec38dc7b6d82 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -41,13 +41,13 @@ def _select_entities_device_id_context_ids_sub_query( json_quoted_entity_ids, json_quoted_device_ids ) ), - apply_entities_hints(select(States.context_id)) + apply_entities_hints(select(States.context_id_bin)) .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) .where(States.entity_id.in_(entity_ids)), ) - return select(union.c.context_id).group_by(union.c.context_id) + return select(union.c.context_id_bin).group_by(union.c.context_id_bin) def _apply_entities_devices_context_union( @@ -77,12 +77,16 @@ def _apply_entities_devices_context_union( apply_events_context_hints( select_events_context_only() .select_from(devices_entities_cte) - .outerjoin(Events, devices_entities_cte.c.context_id == Events.context_id) + .outerjoin( + Events, devices_entities_cte.c.context_id_bin == Events.context_id_bin + ) ).outerjoin(EventData, (Events.data_id == EventData.data_id)), apply_states_context_hints( select_states_context_only() .select_from(devices_entities_cte) - .outerjoin(States, devices_entities_cte.c.context_id == States.context_id) + .outerjoin( + States, devices_entities_cte.c.context_id_bin == States.context_id_bin + ) ), ) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 4e099a3b17fb..7e3f08d7abd7 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -89,6 +89,7 @@ from .tasks import ( ChangeStatisticsUnitTask, ClearStatisticsTask, CommitTask, + ContextIDMigrationTask, DatabaseLockTask, EventTask, ImportStatisticsTask, @@ -687,6 +688,7 @@ class Recorder(threading.Thread): _LOGGER.debug("Recorder processing the queue") self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) + self.queue_task(ContextIDMigrationTask()) self._run_event_loop() self._shutdown() @@ -1146,6 +1148,10 @@ class Recorder(threading.Thread): """Run post schema migration tasks.""" migration.post_schema_migration(self, old_version, new_version) + def _migrate_context_ids(self) -> bool: + """Migrate context ids if needed.""" + return migration.migrate_context_ids(self) + def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 9a059c570c6c..f794c64714e0 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -21,6 +21,7 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, @@ -55,8 +56,12 @@ from .models import ( StatisticData, StatisticDataTimestamp, StatisticMetaData, + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, datetime_to_timestamp_or_none, process_timestamp, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, ) @@ -66,7 +71,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 35 +SCHEMA_VERSION = 36 _LOGGER = logging.getLogger(__name__) @@ -108,8 +113,9 @@ TABLES_TO_CHECK = [ LAST_UPDATED_INDEX_TS = "ix_states_last_updated_ts" ENTITY_ID_LAST_UPDATED_INDEX_TS = "ix_states_entity_id_last_updated_ts" -EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" -STATES_CONTEXT_ID_INDEX = "ix_states_context_id" +EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" +STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" +CONTEXT_ID_BIN_MAX_LENGTH = 16 _DEFAULT_TABLE_ARGS = { "mysql_default_charset": "utf8mb4", @@ -174,6 +180,12 @@ class Events(Base): # Used for fetching events at a specific time # see logbook Index("ix_events_event_type_time_fired_ts", "event_type", "time_fired_ts"), + Index( + EVENTS_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_EVENTS @@ -190,18 +202,27 @@ class Events(Base): DATETIME_TYPE ) # no longer used for new rows time_fired_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True) - context_id: Mapped[str | None] = mapped_column( + context_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True ) - context_user_id: Mapped[str | None] = mapped_column( + context_user_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) - context_parent_id: Mapped[str | None] = mapped_column( + context_parent_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) data_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("event_data.data_id"), index=True ) + context_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_user_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_parent_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) event_data_rel: Mapped[EventData | None] = relationship("EventData") def __repr__(self) -> str: @@ -234,17 +255,20 @@ class Events(Base): origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), time_fired=None, time_fired_ts=dt_util.utc_to_timestamp(event.time_fired), - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, + context_id=None, + context_id_bin=ulid_to_bytes_or_none(event.context.id), + context_user_id=None, + context_user_id_bin=uuid_hex_to_bytes_or_none(event.context.user_id), + context_parent_id=None, + context_parent_id_bin=ulid_to_bytes_or_none(event.context.parent_id), ) def to_native(self, validate_entity_id: bool = True) -> Event | None: """Convert to a native HA Event.""" context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, + id=bytes_to_ulid_or_none(self.context_id_bin), + user_id=bytes_to_uuid_hex_or_none(self.context_user_id), + parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: return Event( @@ -316,6 +340,12 @@ class States(Base): # Used for fetching the state of entities at a specific time # (get_states in history.py) Index(ENTITY_ID_LAST_UPDATED_INDEX_TS, "entity_id", "last_updated_ts"), + Index( + STATES_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), _DEFAULT_TABLE_ARGS, ) __tablename__ = TABLE_STATES @@ -344,13 +374,13 @@ class States(Base): attributes_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("state_attributes.attributes_id"), index=True ) - context_id: Mapped[str | None] = mapped_column( + context_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True ) - context_user_id: Mapped[str | None] = mapped_column( + context_user_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) - context_parent_id: Mapped[str | None] = mapped_column( + context_parent_id: Mapped[str | None] = mapped_column( # no longer used String(MAX_LENGTH_EVENT_CONTEXT_ID) ) origin_idx: Mapped[int | None] = mapped_column( @@ -358,6 +388,15 @@ class States(Base): ) # 0 is local, 1 is remote old_state: Mapped[States | None] = relationship("States", remote_side=[state_id]) state_attributes: Mapped[StateAttributes | None] = relationship("StateAttributes") + context_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_user_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH), + ) + context_parent_id_bin: Mapped[bytes | None] = mapped_column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -388,9 +427,12 @@ class States(Base): dbstate = States( entity_id=entity_id, attributes=None, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, + context_id=None, + context_id_bin=ulid_to_bytes_or_none(event.context.id), + context_user_id=None, + context_user_id_bin=uuid_hex_to_bytes_or_none(event.context.user_id), + context_parent_id=None, + context_parent_id_bin=ulid_to_bytes_or_none(event.context.parent_id), origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), last_updated=None, last_changed=None, @@ -414,9 +456,9 @@ class States(Base): def to_native(self, validate_entity_id: bool = True) -> State | None: """Convert to an HA state object.""" context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, + id=bytes_to_ulid_or_none(self.context_id_bin), + user_id=bytes_to_uuid_hex_or_none(self.context_user_id), + parent_id=bytes_to_ulid_or_none(self.context_parent_id_bin), ) try: attrs = json_loads_object(self.attributes) if self.attributes else {} diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0b8fe9243ba4..e0f1163491eb 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -7,9 +7,10 @@ from dataclasses import dataclass, replace as dataclass_replace from datetime import timedelta import logging from typing import TYPE_CHECKING, cast +from uuid import UUID import sqlalchemy -from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text +from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update from sqlalchemy.engine import CursorResult, Engine from sqlalchemy.exc import ( DatabaseError, @@ -24,20 +25,28 @@ from sqlalchemy.schema import AddConstraint, DropConstraint from sqlalchemy.sql.expression import true from homeassistant.core import HomeAssistant +from homeassistant.util.ulid import ulid_to_bytes from .const import SupportedDialect from .db_schema import ( + CONTEXT_ID_BIN_MAX_LENGTH, SCHEMA_VERSION, STATISTICS_TABLES, TABLE_STATES, Base, + Events, SchemaChanges, + States, Statistics, StatisticsMeta, StatisticsRuns, StatisticsShortTerm, ) from .models import process_timestamp +from .queries import ( + find_events_context_ids_to_migrate, + find_states_context_ids_to_migrate, +) from .statistics import ( correct_db_schema as statistics_correct_db_schema, delete_statistics_duplicates, @@ -56,7 +65,7 @@ if TYPE_CHECKING: from . import Recorder LIVE_MIGRATION_MIN_SCHEMA_VERSION = 0 - +_EMPTY_CONTEXT_ID = b"\x00" * 16 _LOGGER = logging.getLogger(__name__) @@ -219,7 +228,10 @@ def _create_index( def _drop_index( - session_maker: Callable[[], Session], table_name: str, index_name: str + session_maker: Callable[[], Session], + table_name: str, + index_name: str, + quiet: bool | None = None, ) -> None: """Drop an index from a specified table. @@ -282,33 +294,37 @@ def _drop_index( _LOGGER.debug( "Finished dropping index %s from table %s", index_name, table_name ) - else: - if index_name in ( - "ix_states_entity_id", - "ix_states_context_parent_id", - "ix_statistics_short_term_statistic_id_start", - "ix_statistics_statistic_id_start", - ): - # ix_states_context_parent_id was only there on nightly so we do not want - # to generate log noise or issues about it. - # - # ix_states_entity_id was only there for users who upgraded from schema - # version 8 or earlier. Newer installs will not have it so we do not - # want to generate log noise or issues about it. - # - # ix_statistics_short_term_statistic_id_start and ix_statistics_statistic_id_start - # were only there for users who upgraded from schema version 23 or earlier. - return + return - _LOGGER.warning( - ( - "Failed to drop index %s from table %s. Schema " - "Migration will continue; this is not a " - "critical operation" - ), - index_name, - table_name, - ) + if quiet: + return + + if index_name in ( + "ix_states_entity_id", + "ix_states_context_parent_id", + "ix_statistics_short_term_statistic_id_start", + "ix_statistics_statistic_id_start", + ): + # ix_states_context_parent_id was only there on nightly so we do not want + # to generate log noise or issues about it. + # + # ix_states_entity_id was only there for users who upgraded from schema + # version 8 or earlier. Newer installs will not have it so we do not + # want to generate log noise or issues about it. + # + # ix_statistics_short_term_statistic_id_start and ix_statistics_statistic_id_start + # were only there for users who upgraded from schema version 23 or earlier. + return + + _LOGGER.warning( + ( + "Failed to drop index %s from table %s. Schema " + "Migration will continue; this is not a " + "critical operation" + ), + index_name, + table_name, + ) def _add_columns( @@ -522,10 +538,15 @@ def _apply_update( # noqa: C901 """Perform operations to bring schema up to date.""" dialect = engine.dialect.name big_int = "INTEGER(20)" if dialect == SupportedDialect.MYSQL else "INTEGER" - if dialect in (SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL): + if dialect == SupportedDialect.MYSQL: timestamp_type = "DOUBLE PRECISION" + context_bin_type = f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})" + if dialect == SupportedDialect.POSTGRESQL: + timestamp_type = "DOUBLE PRECISION" + context_bin_type = "BYTEA" else: timestamp_type = "FLOAT" + context_bin_type = "BLOB" if new_version == 1: # This used to create ix_events_time_fired, but it was removed in version 32 @@ -944,6 +965,19 @@ def _apply_update( # noqa: C901 ) # ix_statistics_start and ix_statistics_statistic_id_start are still used # for the post migration cleanup and can be removed in a future version. + elif new_version == 36: + for table in ("states", "events"): + _add_columns( + session_maker, + table, + [ + f"context_id_bin {context_bin_type}", + f"context_user_id_bin {context_bin_type}", + f"context_parent_id_bin {context_bin_type}", + ], + ) + _create_index(session_maker, "events", "ix_events_context_id_bin") + _create_index(session_maker, "states", "ix_states_context_id_bin") else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -1193,6 +1227,67 @@ def _migrate_statistics_columns_to_timestamp( ) +def _context_id_to_bytes(context_id: str | None) -> bytes | None: + """Convert a context_id to bytes.""" + if context_id is None: + return None + if len(context_id) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) + return None + + +def migrate_context_ids(instance: Recorder) -> bool: + """Migrate context_ids to use binary format.""" + _to_bytes = _context_id_to_bytes + session_maker = instance.get_session + _LOGGER.debug("Migrating context_ids to binary format") + with session_scope(session=session_maker()) as session: + if events := session.execute(find_events_context_ids_to_migrate()).all(): + session.execute( + update(Events), + [ + { + "event_id": event_id, + "context_id": None, + "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, + "context_user_id": None, + "context_user_id_bin": _to_bytes(context_user_id), + "context_parent_id": None, + "context_parent_id_bin": _to_bytes(context_parent_id), + } + for event_id, context_id, context_user_id, context_parent_id in events + ], + ) + if states := session.execute(find_states_context_ids_to_migrate()).all(): + session.execute( + update(States), + [ + { + "state_id": state_id, + "context_id": None, + "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, + "context_user_id": None, + "context_user_id_bin": _to_bytes(context_user_id), + "context_parent_id": None, + "context_parent_id_bin": _to_bytes(context_parent_id), + } + for state_id, context_id, context_user_id, context_parent_id in states + ], + ) + # If there is more work to do return False + # so that we can be called again + is_done = not (events or states) + + if is_done: + _drop_index(session_maker, "events", "ix_events_context_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_context_id", quiet=True) + + _LOGGER.debug("Migrating context_ids to binary format: done=%s", is_done) + return is_done + + def _initialize_database(session: Session) -> bool: """Initialize a new database. diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index acdf61743f9c..053c870d8a0c 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,10 +1,13 @@ """Models for Recorder.""" from __future__ import annotations +from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timedelta +from functools import lru_cache import logging from typing import Any, Literal, TypedDict, overload +from uuid import UUID from awesomeversion import AwesomeVersion from sqlalchemy.engine.row import Row @@ -18,6 +21,7 @@ from homeassistant.const import ( from homeassistant.core import Context, State import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads_object +from homeassistant.util.ulid import bytes_to_ulid, ulid_to_bytes from .const import SupportedDialect @@ -155,6 +159,40 @@ def timestamp_to_datetime_or_none(ts: float | None) -> datetime | None: return dt_util.utc_from_timestamp(ts) +def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: + """Convert an ulid to bytes.""" + if ulid is None: + return None + return ulid_to_bytes(ulid) + + +def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a ulid.""" + if _bytes is None: + return None + return bytes_to_ulid(_bytes) + + +@lru_cache(maxsize=16) +def uuid_hex_to_bytes_or_none(uuid_hex: str | None) -> bytes | None: + """Convert a uuid hex to bytes.""" + if uuid_hex is None: + return None + with suppress(ValueError): + return UUID(hex=uuid_hex).bytes + return None + + +@lru_cache(maxsize=16) +def bytes_to_uuid_hex_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a uuid hex.""" + if _bytes is None: + return None + with suppress(ValueError): + return UUID(bytes=_bytes).hex + return None + + class LazyStatePreSchema31(State): """A lazy version of core State before schema 31.""" diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index d93a6b0d62b1..217b7ed11bbc 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -667,3 +667,31 @@ def find_legacy_row() -> StatementLambdaElement: # https://github.com/sqlalchemy/sqlalchemy/issues/9189 # pylint: disable-next=not-callable return lambda_stmt(lambda: select(func.max(States.event_id))) + + +def find_events_context_ids_to_migrate() -> StatementLambdaElement: + """Find events context_ids to migrate.""" + return lambda_stmt( + lambda: select( + Events.event_id, + Events.context_id, + Events.context_user_id, + Events.context_parent_id, + ) + .filter(Events.context_id_bin.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def find_states_context_ids_to_migrate() -> StatementLambdaElement: + """Find events context_ids to migrate.""" + return lambda_stmt( + lambda: select( + States.state_id, + States.context_id, + States.context_user_id, + States.context_parent_id, + ) + .filter(States.context_id_bin.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index c8ad1aeb897f..37a027725726 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Callable, Iterable from dataclasses import dataclass from datetime import datetime +import logging import threading from typing import TYPE_CHECKING, Any @@ -18,6 +19,9 @@ from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData from .util import periodic_db_cleanups +_LOGGER = logging.getLogger(__name__) + + if TYPE_CHECKING: from .core import Recorder @@ -339,3 +343,16 @@ class AdjustLRUSizeTask(RecorderTask): def run(self, instance: Recorder) -> None: """Handle the task to adjust the size.""" instance._adjust_lru_size() # pylint: disable=[protected-access] + + +@dataclass +class ContextIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate context ids.""" + + commit_before = False + + def run(self, instance: Recorder) -> None: + """Run context id migration task.""" + if not instance._migrate_context_ids(): # pylint: disable=[protected-access] + # Schedule a new migration task if this one didn't finish + instance.queue_task(ContextIDMigrationTask()) diff --git a/homeassistant/util/ulid.py b/homeassistant/util/ulid.py index 304a42ec6105..643286cedb97 100644 --- a/homeassistant/util/ulid.py +++ b/homeassistant/util/ulid.py @@ -3,9 +3,9 @@ from __future__ import annotations import time -from ulid_transform import ulid_at_time, ulid_hex +from ulid_transform import bytes_to_ulid, ulid_at_time, ulid_hex, ulid_to_bytes -__all__ = ["ulid", "ulid_hex", "ulid_at_time"] +__all__ = ["ulid", "ulid_hex", "ulid_at_time", "ulid_to_bytes", "bytes_to_ulid"] def ulid(timestamp: float | None = None) -> str: diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index e6bce9e6fbc0..d08366e2f1bb 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -6,7 +6,11 @@ from typing import Any from homeassistant.components import logbook from homeassistant.components.logbook import processor -from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat +from homeassistant.components.recorder.models import ( + process_timestamp_to_utc_isoformat, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, +) from homeassistant.core import Context from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder @@ -28,9 +32,13 @@ class MockRow: self.data = data self.time_fired = dt_util.utcnow() self.time_fired_ts = dt_util.utc_to_timestamp(self.time_fired) - self.context_parent_id = context.parent_id if context else None - self.context_user_id = context.user_id if context else None - self.context_id = context.id if context else None + self.context_parent_id_bin = ( + ulid_to_bytes_or_none(context.parent_id) if context else None + ) + self.context_user_id_bin = ( + uuid_hex_to_bytes_or_none(context.user_id) if context else None + ) + self.context_id_bin = ulid_to_bytes_or_none(context.id) if context else None self.state = None self.entity_id = None self.state_id = None diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index bb83c1fdb5cd..a3e240f682fe 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -323,9 +323,9 @@ def create_state_changed_event_from_old_new( "event_data", "time_fired", "time_fired_ts", - "context_id", - "context_user_id", - "context_parent_id", + "context_id_bin", + "context_user_id_bin", + "context_parent_id_bin", "state", "entity_id", "domain", @@ -349,12 +349,12 @@ def create_state_changed_event_from_old_new( row.entity_id = entity_id row.domain = entity_id and ha.split_entity_id(entity_id)[0] row.context_only = False - row.context_id = None + row.context_id_bin = None row.friendly_name = None row.icon = None row.old_format_icon = None - row.context_user_id = None - row.context_parent_id = None + row.context_user_id_bin = None + row.context_parent_id_bin = None row.old_state_id = old_state and 1 row.state_id = new_state and 1 return LazyEventPartialState(row, {}) @@ -966,7 +966,7 @@ async def test_logbook_entity_context_id( await async_recorder_block_till_done(hass) context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -1027,7 +1027,7 @@ async def test_logbook_entity_context_id( # A service call light_turn_off_service_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set("light.switch", STATE_ON) @@ -1120,7 +1120,7 @@ async def test_logbook_context_id_automation_script_started_manually( # An Automation automation_entity_id_test = "automation.alarm" automation_context = ha.Context( - id="fc5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVCCC", user_id="f400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( @@ -1129,7 +1129,7 @@ async def test_logbook_context_id_automation_script_started_manually( context=automation_context, ) script_context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( @@ -1141,7 +1141,7 @@ async def test_logbook_context_id_automation_script_started_manually( hass.bus.async_fire(EVENT_HOMEASSISTANT_START) script_2_context = ha.Context( - id="1234", + id="01GTDGKBCH00GW0X476W5TVEEE", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( @@ -1172,12 +1172,12 @@ async def test_logbook_context_id_automation_script_started_manually( assert json_dict[0]["entity_id"] == "automation.alarm" assert "context_entity_id" not in json_dict[0] assert json_dict[0]["context_user_id"] == "f400facee45711eaa9308bfd3d19e474" - assert json_dict[0]["context_id"] == "fc5bd62de45711eaaeb351041eec8dd9" + assert json_dict[0]["context_id"] == "01GTDGKBCH00GW0X476W5TVCCC" assert json_dict[1]["entity_id"] == "script.mock_script" assert "context_entity_id" not in json_dict[1] assert json_dict[1]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474" - assert json_dict[1]["context_id"] == "ac5bd62de45711eaaeb351041eec8dd9" + assert json_dict[1]["context_id"] == "01GTDGKBCH00GW0X476W5TVAAA" assert json_dict[2]["domain"] == "homeassistant" @@ -1185,7 +1185,7 @@ async def test_logbook_context_id_automation_script_started_manually( assert json_dict[3]["name"] == "Mock script" assert "context_entity_id" not in json_dict[1] assert json_dict[3]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474" - assert json_dict[3]["context_id"] == "1234" + assert json_dict[3]["context_id"] == "01GTDGKBCH00GW0X476W5TVEEE" assert json_dict[4]["entity_id"] == "switch.new" assert json_dict[4]["state"] == "off" @@ -1209,7 +1209,7 @@ async def test_logbook_entity_context_parent_id( await async_recorder_block_till_done(hass) context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -1222,8 +1222,8 @@ async def test_logbook_entity_context_parent_id( ) child_context = ha.Context( - id="2798bfedf8234b5e9f4009c91f48f30c", - parent_id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVDDD", + parent_id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( @@ -1274,8 +1274,8 @@ async def test_logbook_entity_context_parent_id( # A state change via service call with the script as the parent light_turn_off_service_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", - parent_id="2798bfedf8234b5e9f4009c91f48f30c", + id="01GTDGKBCH00GW0X476W5TVBFC", + parent_id="01GTDGKBCH00GW0X476W5TVDDD", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set("light.switch", STATE_ON) @@ -1299,8 +1299,8 @@ async def test_logbook_entity_context_parent_id( # An event with a parent event, but the parent event isn't available missing_parent_context = ha.Context( - id="fc40b9a0d1f246f98c34b33c76228ee6", - parent_id="c8ce515fe58e442f8664246c65ed964f", + id="01GTDGKBCH00GW0X476W5TEDDD", + parent_id="01GTDGKBCH00GW0X276W5TEDDD", user_id="485cacf93ef84d25a99ced3126b921d2", ) logbook.async_log_entry( @@ -1423,7 +1423,7 @@ async def test_logbook_context_from_template( await hass.async_block_till_done() switch_turn_off_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set( @@ -1506,7 +1506,7 @@ async def test_logbook_( await hass.async_block_till_done() switch_turn_off_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set( @@ -1692,7 +1692,7 @@ async def test_logbook_multiple_entities( await hass.async_block_till_done() switch_turn_off_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set( @@ -2394,7 +2394,7 @@ async def test_get_events( hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) await hass.async_block_till_done() context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -2474,7 +2474,7 @@ async def test_get_events( "id": 5, "type": "logbook/get_events", "start_time": now.isoformat(), - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X476W5TVAAA", } ) response = await client.receive_json() @@ -2651,7 +2651,7 @@ async def test_get_events_with_device_ids( hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) await hass.async_block_till_done() context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -2740,7 +2740,7 @@ async def test_logbook_select_entities_context_id( await async_recorder_block_till_done(hass) context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -2799,7 +2799,7 @@ async def test_logbook_select_entities_context_id( # A service call light_turn_off_service_context = ha.Context( - id="9c5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVBFC", user_id="9400facee45711eaa9308bfd3d19e474", ) hass.states.async_set("light.switch", STATE_ON) @@ -2880,7 +2880,7 @@ async def test_get_events_with_context_state( hass.states.async_set("light.kitchen2", STATE_OFF) context = ha.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X476W5TVAAA", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.states.async_set("binary_sensor.is_light", STATE_OFF, context=context) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 6b21c66de8c1..9b0b3f2221e6 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -159,7 +159,7 @@ async def test_get_events( hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) await hass.async_block_till_done() context = core.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X276W5TEDDD", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -239,7 +239,7 @@ async def test_get_events( "id": 5, "type": "logbook/get_events", "start_time": now.isoformat(), - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", } ) response = await client.receive_json() @@ -448,7 +448,7 @@ async def test_get_events_with_device_ids( hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) await hass.async_block_till_done() context = core.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X276W5TEDDD", user_id="b400facee45711eaa9308bfd3d19e474", ) @@ -1262,7 +1262,7 @@ async def test_subscribe_unsubscribe_logbook_stream( ] context = core.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X276W5TEDDD", user_id="b400facee45711eaa9308bfd3d19e474", ) automation_entity_id_test = "automation.alarm" @@ -1300,7 +1300,7 @@ async def test_subscribe_unsubscribe_logbook_stream( assert msg["type"] == "event" assert msg["event"]["events"] == [ { - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", "context_user_id": "b400facee45711eaa9308bfd3d19e474", "domain": "automation", "entity_id": "automation.alarm", @@ -1313,7 +1313,7 @@ async def test_subscribe_unsubscribe_logbook_stream( "context_domain": "automation", "context_entity_id": "automation.alarm", "context_event_type": "automation_triggered", - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", "context_message": "triggered by state of binary_sensor.dog_food_ready", "context_name": "Mock automation", "context_source": "state of binary_sensor.dog_food_ready", @@ -1365,7 +1365,7 @@ async def test_subscribe_unsubscribe_logbook_stream( "context_domain": "automation", "context_entity_id": "automation.alarm", "context_event_type": "automation_triggered", - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", "context_message": "triggered by state of binary_sensor.dog_food_ready", "context_name": "Mock automation", "context_source": "state of binary_sensor.dog_food_ready", @@ -1395,7 +1395,7 @@ async def test_subscribe_unsubscribe_logbook_stream( "context_domain": "automation", "context_entity_id": "automation.alarm", "context_event_type": "automation_triggered", - "context_id": "ac5bd62de45711eaaeb351041eec8dd9", + "context_id": "01GTDGKBCH00GW0X276W5TEDDD", "context_message": "triggered by state of binary_sensor.dog_food_ready", "context_name": "Mock automation", "context_source": "state of binary_sensor.dog_food_ready", @@ -1990,7 +1990,7 @@ async def test_logbook_stream_match_multiple_entities( hass.states.async_set("binary_sensor.should_not_appear", STATE_ON) hass.states.async_set("binary_sensor.should_not_appear", STATE_OFF) context = core.Context( - id="ac5bd62de45711eaaeb351041eec8dd9", + id="01GTDGKBCH00GW0X276W5TEDDD", user_id="b400facee45711eaa9308bfd3d19e474", ) hass.bus.async_fire( diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index d63e8d59d257..0cd3f4149018 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -27,6 +27,7 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, @@ -92,6 +93,10 @@ DOUBLE_TYPE = ( TIMESTAMP_TYPE = DOUBLE_TYPE +CONTEXT_ID_BIN_MAX_LENGTH = 16 +EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" +STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" + class Events(Base): # type: ignore """Event history data.""" @@ -100,6 +105,12 @@ class Events(Base): # type: ignore # Used for fetching events at a specific time # see logbook Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + Index( + EVENTS_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_EVENTS @@ -121,6 +132,15 @@ class Events(Base): # type: ignore data_id = Column( Integer, ForeignKey("event_data.data_id"), index=True ) # *** Not originally in v23, only added for recorder to startup ok + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok event_data_rel = relationship( "EventData" ) # *** Not originally in v23, only added for recorder to startup ok @@ -191,6 +211,12 @@ class States(Base): # type: ignore # Used for fetching the state of entities at a specific time # (get_states in history.py) Index("ix_states_entity_id_last_updated", "entity_id", "last_updated"), + Index( + STATES_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_STATES @@ -212,6 +238,15 @@ class States(Base): # type: ignore ) # *** Not originally in v23, only added for recorder to startup ok created = Column(DATETIME_TYPE, default=dt_util.utcnow) old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok event = relationship("Events", uselist=False) old_state = relationship("States", remote_side=[state_id]) diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 91f7593969af..7862ad061429 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -23,6 +23,7 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, @@ -96,6 +97,9 @@ LAST_UPDATED_INDEX = "ix_states_last_updated" ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated" EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" STATES_CONTEXT_ID_INDEX = "ix_states_context_id" +CONTEXT_ID_BIN_MAX_LENGTH = 16 +EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" +STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): # type: ignore[misc] @@ -193,6 +197,12 @@ class Events(Base): # type: ignore[misc,valid-type] # Used for fetching events at a specific time # see logbook Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + Index( + EVENTS_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_EVENTS @@ -206,6 +216,15 @@ class Events(Base): # type: ignore[misc,valid-type] context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) data_id = Column(Integer, ForeignKey("event_data.data_id"), index=True) + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v30, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v30, only added for recorder to startup ok event_data_rel = relationship("EventData") def __repr__(self) -> str: @@ -310,6 +329,12 @@ class States(Base): # type: ignore[misc,valid-type] # Used for fetching the state of entities at a specific time # (get_states in history.py) Index(ENTITY_ID_LAST_UPDATED_INDEX, "entity_id", "last_updated"), + Index( + STATES_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_STATES @@ -332,6 +357,15 @@ class States(Base): # type: ignore[misc,valid-type] context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) origin_idx = Column(SmallInteger) # 0 is local, 1 is remote + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v30, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v23, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v30, only added for recorder to startup ok old_state = relationship("States", remote_side=[state_id]) state_attributes = relationship("StateAttributes") diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 19c7e6c69558..730d90e14ba4 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -6,9 +6,10 @@ import sqlite3 import sys import threading from unittest.mock import Mock, PropertyMock, call, patch +import uuid import pytest -from sqlalchemy import create_engine, text +from sqlalchemy import create_engine, inspect, text from sqlalchemy.exc import ( DatabaseError, InternalError, @@ -23,17 +24,25 @@ from homeassistant.components import persistent_notification as pn, recorder from homeassistant.components.recorder import db_schema, migration from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, + Events, RecorderRuns, States, ) +from homeassistant.components.recorder.tasks import ContextIDMigrationTask from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper import homeassistant.util.dt as dt_util +from homeassistant.util.ulid import bytes_to_ulid -from .common import async_wait_recording_done, create_engine_test +from .common import ( + async_recorder_block_till_done, + async_wait_recording_done, + create_engine_test, +) from tests.common import async_fire_time_changed +from tests.typing import RecorderInstanceGenerator ORIG_TZ = dt_util.DEFAULT_TIME_ZONE @@ -535,3 +544,147 @@ def test_raise_if_exception_missing_empty_cause_str() -> None: with pytest.raises(ProgrammingError): migration.raise_if_exception_missing_str(programming_exc, ["not present"]) + + +@pytest.mark.parametrize("enable_migrate_context_ids", [True]) +async def test_migrate_context_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate old uuid context ids and ulid context ids to binary format.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + test_uuid = uuid.uuid4() + uuid_hex = test_uuid.hex + uuid_bin = test_uuid.bytes + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + Events( + event_type="old_uuid_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.452529, + context_id=uuid_hex, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + Events( + event_type="empty_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id=None, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + Events( + event_type="ulid_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", + context_id_bin=None, + context_user_id="9400facee45711eaa9308bfd3d19e474", + context_user_id_bin=None, + context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", + context_parent_id_bin=None, + ), + Events( + event_type="invalid_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="invalid", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(ContextIDMigrationTask()) + await async_recorder_block_till_done(hass) + + def _object_as_dict(obj): + return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events) + .filter( + Events.event_type.in_( + [ + "old_uuid_context_id_event", + "empty_context_id_event", + "ulid_context_id_event", + "invalid_context_id_event", + ] + ) + ) + .all() + ) + assert len(events) == 4 + return {event.event_type: _object_as_dict(event) for event in events} + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + + old_uuid_context_id_event = events_by_type["old_uuid_context_id_event"] + assert old_uuid_context_id_event["context_id"] is None + assert old_uuid_context_id_event["context_user_id"] is None + assert old_uuid_context_id_event["context_parent_id"] is None + assert old_uuid_context_id_event["context_id_bin"] == uuid_bin + assert old_uuid_context_id_event["context_user_id_bin"] is None + assert old_uuid_context_id_event["context_parent_id_bin"] is None + + empty_context_id_event = events_by_type["empty_context_id_event"] + assert empty_context_id_event["context_id"] is None + assert empty_context_id_event["context_user_id"] is None + assert empty_context_id_event["context_parent_id"] is None + assert empty_context_id_event["context_id_bin"] == b"\x00" * 16 + assert empty_context_id_event["context_user_id_bin"] is None + assert empty_context_id_event["context_parent_id_bin"] is None + + ulid_context_id_event = events_by_type["ulid_context_id_event"] + assert ulid_context_id_event["context_id"] is None + assert ulid_context_id_event["context_user_id"] is None + assert ulid_context_id_event["context_parent_id"] is None + assert ( + bytes_to_ulid(ulid_context_id_event["context_id_bin"]) + == "01ARZ3NDEKTSV4RRFFQ69G5FAV" + ) + assert ( + ulid_context_id_event["context_user_id_bin"] + == b"\x94\x00\xfa\xce\xe4W\x11\xea\xa90\x8b\xfd=\x19\xe4t" + ) + assert ( + bytes_to_ulid(ulid_context_id_event["context_parent_id_bin"]) + == "01ARZ3NDEKTSV4RRFFQ69G5FA2" + ) + + invalid_context_id_event = events_by_type["invalid_context_id_event"] + assert invalid_context_id_event["context_id"] is None + assert invalid_context_id_event["context_user_id"] is None + assert invalid_context_id_event["context_parent_id"] is None + assert invalid_context_id_event["context_id_bin"] == b"\x00" * 16 + assert invalid_context_id_event["context_user_id_bin"] is None + assert invalid_context_id_event["context_parent_id_bin"] is None diff --git a/tests/conftest.py b/tests/conftest.py index 9c1eef3ffbd0..ed5a95f1b254 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1138,6 +1138,16 @@ def enable_nightly_purge() -> bool: return False +@pytest.fixture +def enable_migrate_context_ids() -> bool: + """Fixture to control enabling of recorder's context id migration. + + To enable context id migration, tests can be marked with: + @pytest.mark.parametrize("enable_migrate_context_ids", [True]) + """ + return False + + @pytest.fixture def recorder_config() -> dict[str, Any] | None: """Fixture to override recorder config. @@ -1280,6 +1290,7 @@ async def async_setup_recorder_instance( enable_nightly_purge: bool, enable_statistics: bool, enable_statistics_table_validation: bool, + enable_migrate_context_ids: bool, ) -> AsyncGenerator[RecorderInstanceGenerator, None]: """Yield callable to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel @@ -1295,6 +1306,9 @@ async def async_setup_recorder_instance( if enable_statistics_table_validation else itertools.repeat(set()) ) + migrate_context_ids = ( + recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -1307,6 +1321,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.migration.statistics_validate_db_schema", side_effect=stats_validate, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_context_ids", + side_effect=migrate_context_ids, + autospec=True, ): async def async_setup_recorder( From e5ce8e920dca371c3e16009935184b60276b13ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 9 Mar 2023 02:23:33 +0100 Subject: [PATCH 0341/1058] Add paths for add-on changelog and documentation (#89411) --- homeassistant/components/hassio/http.py | 4 ++-- tests/components/hassio/test_http.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 8a8583a7dafb..fecf05f74b4a 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -53,7 +53,7 @@ PATHS_NOT_ONBOARDED = re.compile( r")$" ) -# Authenticated users manage backups + download logs +# Authenticated users manage backups + download logs, changelog and documentation PATHS_ADMIN = re.compile( r"^(?:" r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?" @@ -66,7 +66,7 @@ PATHS_ADMIN = re.compile( r"|multicast/logs" r"|observer/logs" r"|supervisor/logs" - r"|addons/[^/]+/logs" + r"|addons/[^/]+/(changelog|documentation|logs)" r")$" ) diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index cb1dd639ec62..e659fbe4b8f3 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -288,6 +288,8 @@ async def test_forward_request_not_onboarded_unallowed_paths( ("backups/1234abcd/info", True), ("supervisor/logs", True), ("addons/bl_b392/logs", True), + ("addons/bl_b392/changelog", True), + ("addons/bl_b392/documentation", True), ], ) async def test_forward_request_admin_get( From 1a4b14c2171fe4aee01fad87d0da8e47b4082c89 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 9 Mar 2023 08:02:59 +0100 Subject: [PATCH 0342/1058] Fix MQTT rgb light brightness scaling (#89264) * Normalize received RGB colors to 100% brightness * Assert on rgb_color attribute * Use max for RGB to get brightness * Avoid division and add clamp * remove clamp Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- .../components/mqtt/light/schema_basic.py | 8 ++++-- tests/components/mqtt/test_light.py | 25 ++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 153726a89e81..358a97ed30d7 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -495,8 +495,12 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = color_mode if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: rgb = convert_color(*color) - percent_bright = float(color_util.color_RGB_to_hsv(*rgb)[2]) / 100.0 - self._attr_brightness = min(round(percent_bright * 255), 255) + brightness = max(rgb) + self._attr_brightness = brightness + # Normalize the color to 100% brightness + color = tuple( + min(round(channel / brightness * 255), 255) for channel in color + ) return color @callback diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 1e486a3492ce..fcdec1fbfe39 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -636,8 +636,8 @@ async def test_brightness_from_rgb_controlling_scale( } }, ) + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -650,10 +650,29 @@ async def test_brightness_from_rgb_controlling_scale( state = hass.states.get("light.test") assert state.attributes.get("brightness") == 255 - async_fire_mqtt_message(hass, "test_scale_rgb/rgb/status", "127,0,0") + async_fire_mqtt_message(hass, "test_scale_rgb/rgb/status", "128,64,32") state = hass.states.get("light.test") - assert state.attributes.get("brightness") == 127 + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("rgb_color") == (255, 128, 64) + + mqtt_mock.async_publish.reset_mock() + await common.async_turn_on(hass, "light.test", brightness=191) + await hass.async_block_till_done() + + mqtt_mock.async_publish.assert_has_calls( + [ + call("test_scale_rgb/set", "on", 0, False), + call("test_scale_rgb/rgb/set", "191,95,47", 0, False), + ], + any_order=True, + ) + async_fire_mqtt_message(hass, "test_scale_rgb/rgb/status", "191,95,47") + await hass.async_block_till_done() + + state = hass.states.get("light.test") + assert state.attributes.get("brightness") == 191 + assert state.attributes.get("rgb_color") == (255, 127, 63) async def test_controlling_state_via_topic_with_templates( From 5828e9a8d231d426e68141c70ff01339c8e18c80 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Thu, 9 Mar 2023 09:27:53 +0100 Subject: [PATCH 0343/1058] Simplify LivisiEntity inheritance (#89424) * We don't need to inherit Entity. The CoordinatorEntity already does that. * update imports --- homeassistant/components/livisi/entity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 613f55d1b7eb..ebd2b8138528 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -9,14 +9,14 @@ from aiolivisi.const import CAPABILITY_MAP from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LIVISI_REACHABILITY_CHANGE from .coordinator import LivisiDataUpdateCoordinator -class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator], Entity): +class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): """Represents a base livisi entity.""" _attr_has_entity_name = True From c9d5baca75f51c81e5dbdd127eecaad95bc4dc10 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 9 Mar 2023 11:12:29 +0100 Subject: [PATCH 0344/1058] Add hostname to DHCP discovery title (#89426) --- homeassistant/components/reolink/config_flow.py | 3 +-- homeassistant/components/reolink/strings.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index e4bc98cc0f87..15f3dfa613ec 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -95,10 +95,9 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(mac_address) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) - short_mac = mac_address[-8:].upper() self.context["title_placeholders"] = { - "short_mac": short_mac, "ip_address": discovery_info.ip, + "hostname": discovery_info.hostname, } self._host = discovery_info.ip diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index f4cb8a904ffd..3ab77d2b8f43 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{short_mac} ({ip_address})", + "flow_title": "{hostname} ({ip_address})", "step": { "user": { "description": "{error}", From dbebe57d51a666628b61d251369e5ff26bc79fba Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 11:41:59 +0100 Subject: [PATCH 0345/1058] Avoid unnecessary Task in debouncer (#89370) --- homeassistant/helpers/debounce.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index b4a4cde0c1fa..dd536956a83a 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -94,11 +94,6 @@ class Debouncer(Generic[_R_co]): """Handle a finished timer.""" assert self._job is not None - self._timer_task = None - - if not self._execute_at_end_of_timer: - return - self._execute_at_end_of_timer = False # Locked means a call is in progress. Any call is good, so abort. @@ -108,7 +103,7 @@ class Debouncer(Generic[_R_co]): async with self._execute_lock: # Abort if timer got set while we're waiting for the lock. if self._timer_task: - return # type: ignore[unreachable] + return try: task = self.hass.async_run_hass_job(self._job) @@ -117,6 +112,7 @@ class Debouncer(Generic[_R_co]): except Exception: # pylint: disable=broad-except self.logger.exception("Unexpected exception from %s", self.function) + # Schedule a new timer to prevent new runs during cooldown self._schedule_timer() @callback @@ -129,12 +125,16 @@ class Debouncer(Generic[_R_co]): self._execute_at_end_of_timer = False @callback - def _schedule_timer(self) -> None: - """Schedule a timer.""" - self._timer_task = self.hass.loop.call_later( - self.cooldown, - lambda: self.hass.async_create_task( + def _on_debounce(self) -> None: + """Create job task, but only if pending.""" + self._timer_task = None + if self._execute_at_end_of_timer: + self.hass.async_create_task( self._handle_timer_finish(), f"debouncer {self._job} finish cooldown={self.cooldown}, immediate={self.immediate}", - ), - ) + ) + + @callback + def _schedule_timer(self) -> None: + """Schedule a timer.""" + self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce) From c5ff3e99143c0b85f64540acd4687e077ec3d5c0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 11:52:20 +0100 Subject: [PATCH 0346/1058] Add review-process link to PR template (#89430) --- .github/PULL_REQUEST_TEMPLATE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 23b355a223fd..c64efda390e7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -59,6 +59,7 @@ - [ ] Local tests pass. **Your PR cannot be merged unless tests pass** - [ ] There is no commented out code in this PR. - [ ] I have followed the [development checklist][dev-checklist] +- [ ] I have followed the [perfect PR recommendations][perfect-pr] - [ ] The code has been formatted using Black (`black --fast homeassistant tests`) - [ ] Tests have been added to verify that the new code works. @@ -107,3 +108,4 @@ To help with the load of incoming pull requests: [manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html [quality-scale]: https://developers.home-assistant.io/docs/en/next/integration_quality_scale_index.html [docs-repository]: https://github.com/home-assistant/home-assistant.io +[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr From ead3662b7abebe8464767a0b71b9f87e4a29572d Mon Sep 17 00:00:00 2001 From: Jeef Date: Thu, 9 Mar 2023 05:00:31 -0700 Subject: [PATCH 0347/1058] Add quadrafire virtual integration for Intellifire (#89316) --- homeassistant/components/quadrafire/__init__.py | 1 + homeassistant/components/quadrafire/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/quadrafire/__init__.py create mode 100644 homeassistant/components/quadrafire/manifest.json diff --git a/homeassistant/components/quadrafire/__init__.py b/homeassistant/components/quadrafire/__init__.py new file mode 100644 index 000000000000..662e9d088728 --- /dev/null +++ b/homeassistant/components/quadrafire/__init__.py @@ -0,0 +1 @@ +"""Virtual integration for quadrafire.""" diff --git a/homeassistant/components/quadrafire/manifest.json b/homeassistant/components/quadrafire/manifest.json new file mode 100644 index 000000000000..fcd263e3212d --- /dev/null +++ b/homeassistant/components/quadrafire/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "quadrafire", + "name": "Quadra-Fire", + "integration_type": "virtual", + "supported_by": "intellifire" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a79f06bbd36a..cc53cf430d68 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4349,6 +4349,11 @@ "config_flow": false, "iot_class": "calculated" }, + "quadrafire": { + "name": "Quadra-Fire", + "integration_type": "virtual", + "supported_by": "intellifire" + }, "quantum_gateway": { "name": "Quantum Gateway", "integration_type": "hub", From 3989ef88630909ea4dbd7a3201cead74a3760a1f Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 9 Mar 2023 13:01:18 +0100 Subject: [PATCH 0348/1058] Parse attribute reports for ZHA select entity (#89418) * Parse attribute reports for ZHA select entity * Add test for checking that select entity attribute reports are parsed --- homeassistant/components/zha/select.py | 13 ++++ tests/components/zha/test_select.py | 90 +++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index d9074acecfce..b4cbce554033 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -26,6 +26,7 @@ from .core.const import ( CHANNEL_ON_OFF, DATA_ZHA, SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, Strobe, ) from .core.registries import ZHA_ENTITIES @@ -212,6 +213,18 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): ) self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @callback + def async_set_state(self, attr_id: int, attr_name: str, value: Any): + """Handle state update from channel.""" + self.async_write_ha_state() + @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_ON_OFF) class ZHAStartupOnOffSelectEntity( diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index 37738e0fd4ed..714e27147bb3 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -2,17 +2,28 @@ from unittest.mock import call, patch import pytest +from zhaquirks import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + OUTPUT_CLUSTERS, + PROFILE_ID, +) from zigpy.const import SIG_EP_PROFILE import zigpy.profiles.zha as zha +from zigpy.quirks import CustomCluster, CustomDevice +import zigpy.types as t import zigpy.zcl.clusters.general as general +from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster import zigpy.zcl.clusters.security as security +from homeassistant.components.zha.select import AqaraMotionSensitivities from homeassistant.const import STATE_UNKNOWN, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, restore_state from homeassistant.util import dt as dt_util -from .common import find_entity_id +from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE @@ -29,6 +40,7 @@ def select_select_only(): Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ), ): yield @@ -323,3 +335,79 @@ async def test_on_off_select_unsupported( qualifier=select_name.lower(), ) assert entity_id is None + + +class MotionSensitivityQuirk(CustomDevice): + """Quirk with motion sensitivity attribute.""" + + class OppleCluster(CustomCluster, ManufacturerSpecificCluster): + """Aqara manufacturer specific cluster.""" + + cluster_id = 0xFCC0 + ep_attribute = "opple_cluster" + attributes = { + 0x010C: ("motion_sensitivity", t.uint8_t, True), + } + + def __init__(self, *args, **kwargs): + """Initialize.""" + super().__init__(*args, **kwargs) + # populate cache to create config entity + self._attr_cache.update({0x010C: AqaraMotionSensitivities.Medium}) + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [general.Basic.cluster_id, OppleCluster], + OUTPUT_CLUSTERS: [], + }, + } + } + + +@pytest.fixture +async def zigpy_device_aqara_sensor(hass, zigpy_device_mock, zha_device_joined): + """Device tracker zigpy Aqara motion sensor device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + } + }, + manufacturer="LUMI", + model="lumi.motion.ac02", + quirk=MotionSensitivityQuirk, + ) + + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + await hass.async_block_till_done() + return zigpy_device + + +async def test_on_off_select_attribute_report( + hass: HomeAssistant, light, zha_device_restored, zigpy_device_aqara_sensor +) -> None: + """Test ZHA attribute report parsing for select platform.""" + + zha_device = await zha_device_restored(zigpy_device_aqara_sensor) + cluster = zigpy_device_aqara_sensor.endpoints.get(1).opple_cluster + entity_id = await find_entity_id(Platform.SELECT, 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]) + + # test that the state is in default medium state + assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Medium.name + + # send attribute report from device + await send_attributes_report( + hass, cluster, {"motion_sensitivity": AqaraMotionSensitivities.Low} + ) + assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Low.name From c2f69dc59d8e98ac014a4ad489e35f649f909ffe Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Thu, 9 Mar 2023 12:02:12 +0000 Subject: [PATCH 0349/1058] Revert Transmission entities name changes (#89409) --- homeassistant/components/transmission/const.py | 2 +- homeassistant/components/transmission/sensor.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index 94296f53a618..517ef0a853a2 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -1,7 +1,7 @@ """Constants for the Transmission Bittorent Client component.""" DOMAIN = "transmission" -SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle mode"} +SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle Mode"} ORDER_NEWEST_FIRST = "newest_first" ORDER_OLDEST_FIRST = "oldest_first" diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 46d12d6798bf..914777313177 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -38,14 +38,14 @@ async def async_setup_entry( name = config_entry.data[CONF_NAME] dev = [ - TransmissionSpeedSensor(tm_client, name, "Down speed", "download"), - TransmissionSpeedSensor(tm_client, name, "Up speed", "upload"), + TransmissionSpeedSensor(tm_client, name, "Down Speed", "download"), + TransmissionSpeedSensor(tm_client, name, "Up Speed", "upload"), TransmissionStatusSensor(tm_client, name, "Status"), - TransmissionTorrentsSensor(tm_client, name, "Active torrents", "active"), - TransmissionTorrentsSensor(tm_client, name, "Paused torrents", "paused"), - TransmissionTorrentsSensor(tm_client, name, "Total torrents", "total"), - TransmissionTorrentsSensor(tm_client, name, "Completed torrents", "completed"), - TransmissionTorrentsSensor(tm_client, name, "Started torrents", "started"), + TransmissionTorrentsSensor(tm_client, name, "Active Torrents", "active"), + TransmissionTorrentsSensor(tm_client, name, "Paused Torrents", "paused"), + TransmissionTorrentsSensor(tm_client, name, "Total Torrents", "total"), + TransmissionTorrentsSensor(tm_client, name, "Completed Torrents", "completed"), + TransmissionTorrentsSensor(tm_client, name, "Started Torrents", "started"), ] async_add_entities(dev, True) From 86ad8261d8a0172dac92b6d76628752db271a744 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Mar 2023 02:03:08 -1000 Subject: [PATCH 0350/1058] Update logbook queries for SADeprecationWarning (#87108) --- homeassistant/components/logbook/queries/devices.py | 6 ++++-- homeassistant/components/logbook/queries/entities.py | 2 +- .../components/logbook/queries/entities_and_devices.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 303313602df9..d84a53431089 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -33,8 +33,10 @@ def _select_device_id_context_ids_sub_query( json_quotable_device_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple devices.""" - inner = select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_device_id_matchers(json_quotable_device_ids) + inner = ( + select_events_context_id_subquery(start_day, end_day, event_types) + .where(apply_event_device_id_matchers(json_quotable_device_ids)) + .subquery() ) return select(inner.c.context_id_bin).group_by(inner.c.context_id_bin) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 2c095d1b0519..10ca6fad1349 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -47,7 +47,7 @@ def _select_entities_context_ids_sub_query( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) .where(States.entity_id.in_(entity_ids)), - ) + ).subquery() return select(union.c.context_id_bin).group_by(union.c.context_id_bin) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index ec38dc7b6d82..b4a1c7bc9f8c 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -46,7 +46,7 @@ def _select_entities_device_id_context_ids_sub_query( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) .where(States.entity_id.in_(entity_ids)), - ) + ).subquery() return select(union.c.context_id_bin).group_by(union.c.context_id_bin) From 9faf25132182d0c6bcd987ecfa9b7648283e6239 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 13:05:04 +0100 Subject: [PATCH 0351/1058] Add missing mock in buienradar config flow tests (#89420) --- tests/components/buienradar/conftest.py | 14 +++++++++++ .../components/buienradar/test_config_flow.py | 25 ++++++++----------- 2 files changed, 24 insertions(+), 15 deletions(-) create mode 100644 tests/components/buienradar/conftest.py diff --git a/tests/components/buienradar/conftest.py b/tests/components/buienradar/conftest.py new file mode 100644 index 000000000000..b896e54e6287 --- /dev/null +++ b/tests/components/buienradar/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for buienradar2.""" +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.buienradar.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/buienradar/test_config_flow.py b/tests/components/buienradar/test_config_flow.py index 955a7b85dc86..6a7db5e90660 100644 --- a/tests/components/buienradar/test_config_flow.py +++ b/tests/components/buienradar/test_config_flow.py @@ -1,5 +1,6 @@ """Test the buienradar2 config flow.""" -from unittest.mock import patch + +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.buienradar.const import DOMAIN @@ -11,6 +12,8 @@ from tests.common import MockConfigEntry TEST_LATITUDE = 51.5288504 TEST_LONGITUDE = 5.4002156 +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_config_flow_setup_(hass: HomeAssistant) -> None: """Test setup of camera.""" @@ -22,13 +25,10 @@ async def test_config_flow_setup_(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - "homeassistant.components.buienradar.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_LATITUDE: TEST_LATITUDE, CONF_LONGITUDE: TEST_LONGITUDE}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_LATITUDE: TEST_LATITUDE, CONF_LONGITUDE: TEST_LONGITUDE}, + ) assert result["type"] == "create_entry" assert result["title"] == f"{TEST_LATITUDE},{TEST_LONGITUDE}" @@ -92,13 +92,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: user_input={"country_code": "BE", "delta": 450, "timeframe": 30}, ) - with patch( - "homeassistant.components.buienradar.async_setup_entry", return_value=True - ), patch( - "homeassistant.components.buienradar.async_unload_entry", return_value=True - ): - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + await hass.async_block_till_done() assert entry.options == {"country_code": "BE", "delta": 450, "timeframe": 30} From b0631fed1d829242e4aedc7fee7385969e08c229 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 13:05:32 +0100 Subject: [PATCH 0352/1058] Add missing mock in braviatv config flow tests (#89419) --- tests/components/braviatv/conftest.py | 14 ++++++++++++++ tests/components/braviatv/test_config_flow.py | 8 ++------ 2 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 tests/components/braviatv/conftest.py diff --git a/tests/components/braviatv/conftest.py b/tests/components/braviatv/conftest.py new file mode 100644 index 000000000000..e4ee2ebc8682 --- /dev/null +++ b/tests/components/braviatv/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for Bravia TV.""" +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.braviatv.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index e7dd0046ec60..3dffeaf527c8 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -84,6 +84,8 @@ FAKE_BRAVIA_SSDP = ssdp.SsdpServiceInfo( }, ) +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" @@ -111,8 +113,6 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: ), patch("pybravia.BraviaClient.set_wol_mode"), patch( "pybravia.BraviaClient.get_system_info", return_value=BRAVIA_SYSTEM_INFO, - ), patch( - "homeassistant.components.braviatv.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -302,8 +302,6 @@ async def test_create_entry(hass: HomeAssistant) -> None: ), patch("pybravia.BraviaClient.set_wol_mode"), patch( "pybravia.BraviaClient.get_system_info", return_value=BRAVIA_SYSTEM_INFO, - ), patch( - "homeassistant.components.braviatv.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} @@ -343,8 +341,6 @@ async def test_create_entry_psk(hass: HomeAssistant) -> None: ), patch( "pybravia.BraviaClient.get_system_info", return_value=BRAVIA_SYSTEM_INFO, - ), patch( - "homeassistant.components.braviatv.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} From c6d2824afef9b8af9be923030c1b0c405a498b15 Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Fri, 10 Mar 2023 01:06:27 +1300 Subject: [PATCH 0353/1058] Disable some less commonly used starlink entities by default (#87869) Co-authored-by: J. Nick Koston --- homeassistant/components/starlink/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index af745c6f1555..bb84f0322429 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -75,6 +75,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, + entity_registry_enabled_default=False, value_fn=lambda data: round(data.status["direction_azimuth"]), ), StarlinkSensorEntityDescription( @@ -84,6 +85,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, + entity_registry_enabled_default=False, value_fn=lambda data: round(data.status["direction_elevation"]), ), StarlinkSensorEntityDescription( @@ -91,6 +93,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( name="Uplink throughput", icon="mdi:upload", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, value_fn=lambda data: round(data.status["uplink_throughput_bps"]), ), @@ -99,6 +102,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( name="Downlink throughput", icon="mdi:download", state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, value_fn=lambda data: round(data.status["downlink_throughput_bps"]), ), From 4a082403eb9886ba2df42c4d9cd3d014265a0b82 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 13:16:36 +0100 Subject: [PATCH 0354/1058] Add missing mock in coronavirus config flow tests (#89428) --- tests/components/coronavirus/conftest.py | 13 +++++++++++-- tests/components/coronavirus/test_config_flow.py | 9 ++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/components/coronavirus/conftest.py b/tests/components/coronavirus/conftest.py index 57128268fd7a..227d9fa2123f 100644 --- a/tests/components/coronavirus/conftest.py +++ b/tests/components/coronavirus/conftest.py @@ -1,10 +1,19 @@ """Test helpers.""" - -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch import pytest +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.coronavirus.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) def mock_cases(): """Mock coronavirus cases.""" diff --git a/tests/components/coronavirus/test_config_flow.py b/tests/components/coronavirus/test_config_flow.py index e641c0e0011a..2fe7ed370e8c 100644 --- a/tests/components/coronavirus/test_config_flow.py +++ b/tests/components/coronavirus/test_config_flow.py @@ -1,14 +1,17 @@ """Test the Coronavirus config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError +import pytest from homeassistant import config_entries from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE from homeassistant.core import HomeAssistant +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -28,7 +31,7 @@ async def test_form(hass: HomeAssistant) -> None: "country": OPTION_WORLDWIDE, } await hass.async_block_till_done() - assert len(hass.states.async_all()) == 4 + mock_setup_entry.assert_called_once() @patch( From 3545209355bffdf0bbf68bc76f9d230ce526713e Mon Sep 17 00:00:00 2001 From: Jeef Date: Thu, 9 Mar 2023 05:17:50 -0700 Subject: [PATCH 0355/1058] Add vermont_castings virtual integration for Intellifire (#89317) --- homeassistant/components/vermont_castings/__init__.py | 1 + homeassistant/components/vermont_castings/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/vermont_castings/__init__.py create mode 100644 homeassistant/components/vermont_castings/manifest.json diff --git a/homeassistant/components/vermont_castings/__init__.py b/homeassistant/components/vermont_castings/__init__.py new file mode 100644 index 000000000000..d2c0b7751430 --- /dev/null +++ b/homeassistant/components/vermont_castings/__init__.py @@ -0,0 +1 @@ +"""Virtual integration for Vermont Castings fireplace.""" diff --git a/homeassistant/components/vermont_castings/manifest.json b/homeassistant/components/vermont_castings/manifest.json new file mode 100644 index 000000000000..301db38c8bde --- /dev/null +++ b/homeassistant/components/vermont_castings/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "vermont_castings", + "name": "Vermont Castings", + "integration_type": "virtual", + "supported_by": "intellifire" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cc53cf430d68..e8350284f13e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5993,6 +5993,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "vermont_castings": { + "name": "Vermont Castings", + "integration_type": "virtual", + "supported_by": "intellifire" + }, "versasense": { "name": "VersaSense", "integration_type": "hub", From dcff2f37f789c94c28de1ab17bcb9527e694ad55 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 13:19:22 +0100 Subject: [PATCH 0356/1058] Add DSL prefix to SFRBox ADSL sensors (#89276) --- .../components/sfr_box/binary_sensor.py | 2 +- homeassistant/components/sfr_box/sensor.py | 22 ++++++++--------- tests/components/sfr_box/const.py | 24 +++++++++---------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index d90c1944aa4d..8b3883c6eee8 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -42,7 +42,7 @@ class SFRBoxBinarySensorEntityDescription( DSL_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[DslInfo], ...] = ( SFRBoxBinarySensorEntityDescription[DslInfo]( key="status", - name="Status", + name="DSL status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda x: x.status == "up", diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 5f4aadce7e20..48d1c16d0ff0 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -46,28 +46,28 @@ class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin[_ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( SFRBoxSensorEntityDescription[DslInfo]( key="linemode", - name="Line mode", + name="DSL line mode", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda x: x.linemode, ), SFRBoxSensorEntityDescription[DslInfo]( key="counter", - name="Counter", + name="DSL counter", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda x: x.counter, ), SFRBoxSensorEntityDescription[DslInfo]( key="crc", - name="CRC", + name="DSL CRC", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda x: x.crc, ), SFRBoxSensorEntityDescription[DslInfo]( key="noise_down", - name="Noise down", + name="DSL noise down", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -77,7 +77,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="noise_up", - name="Noise up", + name="DSL noise up", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -87,7 +87,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="attenuation_down", - name="Attenuation down", + name="DSL attenuation down", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -97,7 +97,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="attenuation_up", - name="Attenuation up", + name="DSL attenuation up", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -107,7 +107,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="rate_down", - name="Rate down", + name="DSL rate down", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -115,7 +115,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="rate_up", - name="Rate up", + name="DSL rate up", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -123,7 +123,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="line_status", - name="Line status", + name="DSL line status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -140,7 +140,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( ), SFRBoxSensorEntityDescription[DslInfo]( key="training", - name="Training", + name="DSL training", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/tests/components/sfr_box/const.py b/tests/components/sfr_box/const.py index 8b7513aaf8c4..fb1694ebef06 100644 --- a/tests/components/sfr_box/const.py +++ b/tests/components/sfr_box/const.py @@ -45,7 +45,7 @@ EXPECTED_ENTITIES = { Platform.BINARY_SENSOR: [ { ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CONNECTIVITY, - ATTR_ENTITY_ID: "binary_sensor.sfr_box_status", + ATTR_ENTITY_ID: "binary_sensor.sfr_box_dsl_status", ATTR_STATE: STATE_ON, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_status", }, @@ -85,26 +85,26 @@ EXPECTED_ENTITIES = { }, { ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_line_mode", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_line_mode", ATTR_STATE: "ADSL2+", ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_linemode", }, { ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_counter", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_counter", ATTR_STATE: "16", ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_counter", }, { ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_crc", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_crc", ATTR_STATE: "0", ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_crc", }, { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_noise_down", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_noise_down", ATTR_STATE: "5.8", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_noise_down", @@ -113,7 +113,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_noise_up", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_noise_up", ATTR_STATE: "6.0", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_noise_up", @@ -122,7 +122,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_attenuation_down", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_attenuation_down", ATTR_STATE: "28.5", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_attenuation_down", @@ -131,7 +131,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_attenuation_up", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_attenuation_up", ATTR_STATE: "20.8", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_attenuation_up", @@ -139,7 +139,7 @@ EXPECTED_ENTITIES = { }, { ATTR_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, - ATTR_ENTITY_ID: "sensor.sfr_box_rate_down", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_rate_down", ATTR_STATE: "5549", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_rate_down", @@ -147,7 +147,7 @@ EXPECTED_ENTITIES = { }, { ATTR_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, - ATTR_ENTITY_ID: "sensor.sfr_box_rate_up", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_rate_up", ATTR_STATE: "187", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_rate_up", @@ -156,7 +156,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_line_status", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_line_status", ATTR_OPTIONS: [ "no_defect", "of_frame", @@ -171,7 +171,7 @@ EXPECTED_ENTITIES = { { ATTR_DEFAULT_DISABLED: True, ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_training", + ATTR_ENTITY_ID: "sensor.sfr_box_dsl_training", ATTR_OPTIONS: [ "idle", "g_994_training", From f903c536fbae7b207c42a01db35968b10cfc3619 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 9 Mar 2023 14:18:19 +0100 Subject: [PATCH 0357/1058] Add Hardkernel ODROID-M1 (#89431) * Add Hardkernel ODROID-M1 Add Hardkernel ODROID-M1 machine. ODROID-M1 is a Rockchip RK3568B2 SoC based single board computer with 4xCortex-A55, NVMe support and up to 8GB of RAM. * Update homeassistant/components/hardkernel/hardware.py Co-authored-by: Franck Nijhof * Fix tests Co-authored-by: Franck Nijhof --- .github/workflows/builder.yml | 1 + homeassistant/components/hardkernel/hardware.py | 9 +++++---- homeassistant/components/hassio/__init__.py | 1 + homeassistant/components/version/const.py | 2 ++ machine/odroid-m1 | 5 +++++ tests/components/hardkernel/test_hardware.py | 2 +- 6 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 machine/odroid-m1 diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 531efe5674ff..cc87e4708c7c 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -232,6 +232,7 @@ jobs: - khadas-vim3 - odroid-c2 - odroid-c4 + - odroid-m1 - odroid-n2 - odroid-xu - qemuarm diff --git a/homeassistant/components/hardkernel/hardware.py b/homeassistant/components/hardkernel/hardware.py index cd83f684eac6..3d4a87b04074 100644 --- a/homeassistant/components/hardkernel/hardware.py +++ b/homeassistant/components/hardkernel/hardware.py @@ -9,10 +9,11 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAMES = { - "odroid-c2": "Hardkernel Odroid-C2", - "odroid-c4": "Hardkernel Odroid-C4", - "odroid-n2": "Home Assistant Blue / Hardkernel Odroid-N2", - "odroid-xu4": "Hardkernel Odroid-XU4", + "odroid-c2": "Hardkernel ODROID-C2", + "odroid-c4": "Hardkernel ODROID-C4", + "odroid-m1": "Hardkernel ODROID-M1", + "odroid-n2": "Home Assistant Blue / Hardkernel ODROID-N2/N2+", + "odroid-xu4": "Hardkernel ODROID-XU4", } diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4f5d8e9d31a1..23936f657670 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -229,6 +229,7 @@ MAP_SERVICE_API = { HARDWARE_INTEGRATIONS = { "odroid-c2": "hardkernel", "odroid-c4": "hardkernel", + "odroid-m1": "hardkernel", "odroid-n2": "hardkernel", "odroid-xu4": "hardkernel", "rpi2": "raspberry_pi", diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py index 1693f79ec649..bdebf9f0255e 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -69,6 +69,7 @@ BOARD_MAP: Final[dict[str, str]] = { "ASUS Tinkerboard": "tinker", "ODROID C2": "odroid-c2", "ODROID C4": "odroid-c4", + "ODROID M1": "odroid-m1", "ODROID N2": "odroid-n2", "ODROID XU4": "odroid-xu4", "Generic AArch64": "generic-aarch64", @@ -97,6 +98,7 @@ VALID_IMAGES: Final = [ "generic-x86-64", "intel-nuc", "odroid-c2", + "odroid-m1", "odroid-n2", "odroid-xu", "qemuarm-64", diff --git a/machine/odroid-m1 b/machine/odroid-m1 new file mode 100644 index 000000000000..be07d6c8abae --- /dev/null +++ b/machine/odroid-m1 @@ -0,0 +1,5 @@ +ARG BUILD_VERSION +FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION + +RUN apk --no-cache add \ + usbutils diff --git a/tests/components/hardkernel/test_hardware.py b/tests/components/hardkernel/test_hardware.py index 7b5a531cbfbe..7e063a9f07a8 100644 --- a/tests/components/hardkernel/test_hardware.py +++ b/tests/components/hardkernel/test_hardware.py @@ -53,7 +53,7 @@ async def test_hardware_info( }, "config_entries": [config_entry.entry_id], "dongle": None, - "name": "Home Assistant Blue / Hardkernel Odroid-N2", + "name": "Home Assistant Blue / Hardkernel ODROID-N2/N2+", "url": None, } ] From 3c27f9ea7de63edf2ede90751c06be23f7cbe45e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 9 Mar 2023 17:58:03 +0100 Subject: [PATCH 0358/1058] Update actions/cache to 3.3.0 (#89438) --- .github/workflows/ci.yaml | 48 ++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 117b64022c10..b65039c42bfe 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -212,7 +212,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.0 with: path: venv key: >- @@ -227,9 +227,10 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} + lookup-only: true key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} @@ -256,7 +257,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -265,7 +266,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -302,7 +303,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -311,7 +312,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -351,7 +352,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -360,7 +361,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -400,7 +401,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -409,7 +410,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -438,7 +439,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -447,7 +448,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -562,15 +563,16 @@ 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.2.6 + uses: actions/cache@v3.3.0 with: path: venv + lookup-only: true key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.0 with: path: ${{ env.PIP_CACHE }} key: >- @@ -624,7 +626,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -656,7 +658,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -689,7 +691,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -740,7 +742,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.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -748,7 +750,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.2.6 + uses: actions/cache@v3.3.0 with: path: .mypy_cache key: >- @@ -799,7 +801,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -852,7 +854,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -978,7 +980,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true @@ -1082,7 +1084,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.2.6 + uses: actions/cache/restore@v3.3.0 with: path: venv fail-on-cache-miss: true From adfd26363539b9f5d8ef3b1d06d7d094edc610eb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 18:12:22 +0100 Subject: [PATCH 0359/1058] Add missing mock in gree config flow tests (#89450) --- tests/components/gree/conftest.py | 12 +++++++++- tests/components/gree/test_config_flow.py | 29 +++++++++++------------ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/tests/components/gree/conftest.py b/tests/components/gree/conftest.py index 6aabb95a1bbd..8ef5f7bb38f8 100644 --- a/tests/components/gree/conftest.py +++ b/tests/components/gree/conftest.py @@ -1,11 +1,21 @@ """Pytest module configuration.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import AsyncMock, patch import pytest from .common import FakeDiscovery, build_device_mock +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.gree.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True, name="discovery") def discovery_fixture(): """Patch the discovery object.""" diff --git a/tests/components/gree/test_config_flow.py b/tests/components/gree/test_config_flow.py index 97820b8928c9..d4a922be4494 100644 --- a/tests/components/gree/test_config_flow.py +++ b/tests/components/gree/test_config_flow.py @@ -1,5 +1,7 @@ """Tests for the Gree Integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN @@ -7,15 +9,15 @@ from homeassistant.core import HomeAssistant from .common import FakeDiscovery +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @patch("homeassistant.components.gree.config_flow.DISCOVERY_TIMEOUT", 0) -async def test_creating_entry_sets_up_climate(hass: HomeAssistant) -> None: +async def test_creating_entry_sets_up_climate( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test setting up Gree creates the climate components.""" with patch( - "homeassistant.components.gree.climate.async_setup_entry", return_value=True - ) as setup, patch( - "homeassistant.components.gree.bridge.Discovery", return_value=FakeDiscovery() - ), patch( "homeassistant.components.gree.config_flow.Discovery", return_value=FakeDiscovery(), ): @@ -31,22 +33,19 @@ async def test_creating_entry_sets_up_climate(hass: HomeAssistant) -> None: await hass.async_block_till_done() - assert len(setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @patch("homeassistant.components.gree.config_flow.DISCOVERY_TIMEOUT", 0) -async def test_creating_entry_has_no_devices(hass: HomeAssistant) -> None: +async def test_creating_entry_has_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test setting up Gree creates the climate components.""" with patch( - "homeassistant.components.gree.climate.async_setup_entry", return_value=True - ) as setup, patch( - "homeassistant.components.gree.bridge.Discovery", return_value=FakeDiscovery() - ) as discovery, patch( "homeassistant.components.gree.config_flow.Discovery", return_value=FakeDiscovery(), - ) as discovery2: + ) as discovery: discovery.return_value.mock_devices = [] - discovery2.return_value.mock_devices = [] result = await hass.config_entries.flow.async_init( GREE_DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -60,4 +59,4 @@ async def test_creating_entry_has_no_devices(hass: HomeAssistant) -> None: await hass.async_block_till_done() - assert len(setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 From 01d8eaa5b68a99115a61d7c1cc5cc4e05e0350ce Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 9 Mar 2023 18:28:02 +0100 Subject: [PATCH 0360/1058] Update frontend to 20230309.0 (#89446) --- 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 da68e48cc087..a4d97201c5fe 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==20230306.0"] + "requirements": ["home-assistant-frontend==20230309.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 915364219d64..b79a0a5ef731 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230306.0 +home-assistant-frontend==20230309.0 home-assistant-intents==2023.2.28 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 9bbcccb1ac64..a83826662770 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230306.0 +home-assistant-frontend==20230309.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cbc7961352fe..a241892ff52c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230306.0 +home-assistant-frontend==20230309.0 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From 4e4608183e1a2618c1f997f4376a2ca8cf58735f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 19:04:07 +0100 Subject: [PATCH 0361/1058] Add missing mock in fibaro config flow tests (#89440) --- tests/components/fibaro/conftest.py | 14 ++++++++++++++ tests/components/fibaro/test_config_flow.py | 8 ++------ 2 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 tests/components/fibaro/conftest.py diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py new file mode 100644 index 000000000000..9f1c87c1d98a --- /dev/null +++ b/tests/components/fibaro/conftest.py @@ -0,0 +1,14 @@ +"""Test helpers.""" +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.fibaro.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index 0854bb532e44..cb3d35d6f438 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -20,6 +20,8 @@ TEST_USERNAME = "user" TEST_PASSWORD = "password" TEST_VERSION = "4.360" +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.fixture(name="fibaro_client", autouse=True) def fibaro_client_fixture(): @@ -69,9 +71,6 @@ async def test_config_flow_user_initiated_success(hass: HomeAssistant) -> None: with patch( "homeassistant.components.fibaro.FibaroClient.connect", return_value=True, - ), patch( - "homeassistant.components.fibaro.async_setup_entry", - return_value=True, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -239,9 +238,6 @@ async def test_reauth_success(hass: HomeAssistant) -> None: with patch( "homeassistant.components.fibaro.FibaroClient.connect", return_value=True - ), patch( - "homeassistant.components.fibaro.async_setup_entry", - return_value=True, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], From f3084165b10ef9eb9c4f1c321da26a200565c884 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 19:04:51 +0100 Subject: [PATCH 0362/1058] Add missing mock in filesize config flow tests (#89441) --- tests/components/filesize/test_config_flow.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/components/filesize/test_config_flow.py b/tests/components/filesize/test_config_flow.py index 7010a3e9a4d4..dd99aa2a723f 100644 --- a/tests/components/filesize/test_config_flow.py +++ b/tests/components/filesize/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Filesize config flow.""" from unittest.mock import patch +import pytest + from homeassistant.components.filesize.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_FILE_PATH @@ -11,6 +13,8 @@ from . import TEST_DIR, TEST_FILE, TEST_FILE_NAME, async_create_file from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_full_user_flow(hass: HomeAssistant) -> None: """Test the full user configuration flow.""" @@ -75,9 +79,6 @@ async def test_flow_fails_on_validation(hass: HomeAssistant) -> None: with patch( "homeassistant.components.filesize.config_flow.pathlib.Path", - ), patch( - "homeassistant.components.filesize.async_setup_entry", - return_value=True, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -91,9 +92,6 @@ async def test_flow_fails_on_validation(hass: HomeAssistant) -> None: hass.config.allowlist_external_dirs = {TEST_DIR} with patch( "homeassistant.components.filesize.config_flow.pathlib.Path", - ), patch( - "homeassistant.components.filesize.async_setup_entry", - return_value=True, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], From 3a4ce260b4b8f5ce6193eae4b6ef7780142b77b5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 19:05:23 +0100 Subject: [PATCH 0363/1058] Add missing mock in freedompro config flow tests (#89442) --- tests/components/freedompro/conftest.py | 12 +++++++++++- tests/components/freedompro/test_config_flow.py | 4 ++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/components/freedompro/conftest.py b/tests/components/freedompro/conftest.py index bc3d73110582..80e13edb8036 100644 --- a/tests/components/freedompro/conftest.py +++ b/tests/components/freedompro/conftest.py @@ -1,9 +1,10 @@ """Fixtures for Freedompro integration tests.""" from __future__ import annotations +from collections.abc import Generator from copy import deepcopy from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -14,6 +15,15 @@ from .const import DEVICES, DEVICES_STATE from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.freedompro.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(autouse=True) def mock_freedompro(): """Mock freedompro get_list and get_states.""" diff --git a/tests/components/freedompro/test_config_flow.py b/tests/components/freedompro/test_config_flow.py index 6fee3694ec72..dc55ba037baa 100644 --- a/tests/components/freedompro/test_config_flow.py +++ b/tests/components/freedompro/test_config_flow.py @@ -1,6 +1,8 @@ """Define tests for the Freedompro config flow.""" from unittest.mock import patch +import pytest + from homeassistant import data_entry_flow from homeassistant.components.freedompro.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -13,6 +15,8 @@ VALID_CONFIG = { CONF_API_KEY: "ksdjfgslkjdfksjdfksjgfksjd", } +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" From 4f29e1e18015c27884d16a3ee709b951ba53a10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 9 Mar 2023 19:06:35 +0100 Subject: [PATCH 0364/1058] Add stats sensors for core and supervisor (#89455) * Add stats sensors for core and supervisor * Update homeassistant/components/hassio/__init__.py --- homeassistant/components/hassio/__init__.py | 36 ++++++++++++- homeassistant/components/hassio/handler.py | 16 ++++++ homeassistant/components/hassio/sensor.py | 52 +++++++++++++++++-- tests/components/hassio/test_binary_sensor.py | 32 ++++++++++++ tests/components/hassio/test_diagnostics.py | 32 ++++++++++++ tests/components/hassio/test_handler.py | 28 ++++++++++ tests/components/hassio/test_init.py | 48 ++++++++++++++--- tests/components/hassio/test_sensor.py | 32 ++++++++++++ tests/components/hassio/test_update.py | 32 ++++++++++++ 9 files changed, 295 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 23936f657670..25f3477bff14 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -115,11 +115,13 @@ CONFIG_SCHEMA = vol.Schema( DATA_CORE_INFO = "hassio_core_info" +DATA_CORE_STATS = "hassio_core_stats" DATA_HOST_INFO = "hassio_host_info" DATA_STORE = "hassio_store" DATA_INFO = "hassio_info" DATA_OS_INFO = "hassio_os_info" DATA_SUPERVISOR_INFO = "hassio_supervisor_info" +DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" @@ -301,6 +303,26 @@ def get_addons_stats(hass): return hass.data.get(DATA_ADDONS_STATS) +@callback +@bind_hass +def get_core_stats(hass): + """Return core stats. + + Async friendly. + """ + return hass.data.get(DATA_CORE_STATS) + + +@callback +@bind_hass +def get_supervisor_stats(hass): + """Return supervisor stats. + + Async friendly. + """ + return hass.data.get(DATA_SUPERVISOR_STATS) + + @callback @bind_hass def get_addons_changelogs(hass): @@ -747,8 +769,14 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): if self.is_hass_os: new_data[DATA_KEY_OS] = get_os_info(self.hass) - new_data[DATA_KEY_CORE] = get_core_info(self.hass) - new_data[DATA_KEY_SUPERVISOR] = supervisor_info + new_data[DATA_KEY_CORE] = { + **(get_core_info(self.hass) or {}), + **get_core_stats(self.hass), + } + new_data[DATA_KEY_SUPERVISOR] = { + **supervisor_info, + **get_supervisor_stats(self.hass), + } # If this is the initial refresh, register all addons and return the dict if not self.data: @@ -805,12 +833,16 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): ( self.hass.data[DATA_INFO], self.hass.data[DATA_CORE_INFO], + self.hass.data[DATA_CORE_STATS], self.hass.data[DATA_SUPERVISOR_INFO], + self.hass.data[DATA_SUPERVISOR_STATS], self.hass.data[DATA_OS_INFO], ) = await asyncio.gather( self.hassio.get_info(), self.hassio.get_core_info(), + self.hassio.get_core_stats(), self.hassio.get_supervisor_info(), + self.hassio.get_supervisor_stats(), self.hassio.get_os_info(), ) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 762df4f79ca1..d7af26851d05 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -319,6 +319,14 @@ class HassIO: """ return self.send_command(f"/addons/{addon}/info", method="get") + @api_data + def get_core_stats(self): + """Return stats for the core. + + This method returns a coroutine. + """ + return self.send_command("/core/stats", method="get") + @api_data def get_addon_stats(self, addon): """Return stats for an Add-on. @@ -327,6 +335,14 @@ class HassIO: """ return self.send_command(f"/addons/{addon}/stats", method="get") + @api_data + def get_supervisor_stats(self): + """Return stats for the supervisor. + + This method returns a coroutine. + """ + return self.send_command("/supervisor/stats", method="get") + def get_addon_changelog(self, addon): """Return changelog for an Add-on. diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index 31e728a97367..a5b0b3a725ff 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -18,9 +18,16 @@ from .const import ( ATTR_VERSION, ATTR_VERSION_LATEST, DATA_KEY_ADDONS, + DATA_KEY_CORE, DATA_KEY_OS, + DATA_KEY_SUPERVISOR, +) +from .entity import ( + HassioAddonEntity, + HassioCoreEntity, + HassioOSEntity, + HassioSupervisorEntity, ) -from .entity import HassioAddonEntity, HassioOSEntity COMMON_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( @@ -35,7 +42,7 @@ COMMON_ENTITY_DESCRIPTIONS = ( ), ) -ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( +STATS_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_CPU_PERCENT, @@ -54,7 +61,10 @@ ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + ( ), ) +ADDON_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS + STATS_ENTITY_DESCRIPTIONS +CORE_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS +SUPERVISOR_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS async def async_setup_entry( @@ -65,7 +75,9 @@ async def async_setup_entry( """Sensor set up for Hass.io config entry.""" coordinator = hass.data[ADDONS_COORDINATOR] - entities: list[HassioOSSensor | HassioAddonSensor] = [] + entities: list[ + HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor + ] = [] for addon in coordinator.data[DATA_KEY_ADDONS].values(): for entity_description in ADDON_ENTITY_DESCRIPTIONS: @@ -77,6 +89,22 @@ async def async_setup_entry( ) ) + for entity_description in CORE_ENTITY_DESCRIPTIONS: + entities.append( + CoreSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + + for entity_description in SUPERVISOR_ENTITY_DESCRIPTIONS: + entities.append( + SupervisorSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + if coordinator.is_hass_os: for entity_description in OS_ENTITY_DESCRIPTIONS: entities.append( @@ -107,3 +135,21 @@ class HassioOSSensor(HassioOSEntity, SensorEntity): def native_value(self) -> str: """Return native value of entity.""" return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] + + +class CoreSensor(HassioCoreEntity, SensorEntity): + """Sensor to track a core attribute.""" + + @property + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_CORE][self.entity_description.key] + + +class SupervisorSensor(HassioSupervisorEntity, SensorEntity): + """Sensor to track a supervisor attribute.""" + + @property + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_SUPERVISOR][self.entity_description.key] diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index 133074d7c9de..854a6782b1ac 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -120,6 +120,38 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") aioclient_mock.get( "http://127.0.0.1/addons/test/info", diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 7b89cb6c99fe..b3d47e93afd6 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -125,6 +125,38 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") aioclient_mock.get( "http://127.0.0.1/addons/test/info", diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 64e9e1c31cc5..c7075dba9324 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -226,6 +226,34 @@ async def test_api_addon_stats( assert aioclient_mock.call_count == 1 +async def test_api_core_stats( + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API Add-on stats.""" + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={"result": "ok", "data": {"memory_percent": 0.01}}, + ) + + data = await hassio_handler.get_core_stats() + assert data["memory_percent"] == 0.01 + assert aioclient_mock.call_count == 1 + + +async def test_api_supervisor_stats( + hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API Add-on stats.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={"result": "ok", "data": {"memory_percent": 0.01}}, + ) + + data = await hassio_handler.get_supervisor_stats() + assert data["memory_percent"] == 0.01 + assert aioclient_mock.call_count == 1 + + async def test_api_discovery_message( hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index ee7e9cfa7093..a752fe1b677e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -124,6 +124,38 @@ def mock_all(aioclient_mock, request, os_info): ], }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get( "http://127.0.0.1/addons/test/stats", json={ @@ -210,7 +242,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -254,7 +286,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 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"] @@ -273,7 +305,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 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"] @@ -290,7 +322,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 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"] @@ -370,7 +402,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 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 @@ -387,7 +419,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -407,7 +439,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -822,7 +854,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 18 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 4088ba631f49..99b1db2a99b3 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -113,6 +113,38 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") aioclient_mock.get( "http://127.0.0.1/addons/test/info", diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 20a46da0511b..547e32dfddbe 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -127,6 +127,38 @@ def mock_all(aioclient_mock, request): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") aioclient_mock.get( "http://127.0.0.1/addons/test/info", From 3796a7385642f2f42296546dd9d11d23397cb319 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Thu, 9 Mar 2023 19:08:55 +0100 Subject: [PATCH 0365/1058] Add device info to edl21 (#89327) --- homeassistant/components/edl21/const.py | 2 ++ homeassistant/components/edl21/sensor.py | 44 +++++++++++++----------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/edl21/const.py b/homeassistant/components/edl21/const.py index f57966a00033..2bde0ff379a9 100644 --- a/homeassistant/components/edl21/const.py +++ b/homeassistant/components/edl21/const.py @@ -10,3 +10,5 @@ CONF_SERIAL_PORT = "serial_port" SIGNAL_EDL21_TELEGRAM = "edl21_telegram" DEFAULT_TITLE = "Smart Meter" + +DEFAULT_DEVICE_NAME = "Smart Meter" diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 355d448e3016..35992b96104b 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -32,12 +32,19 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity import 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.util.dt import utcnow -from .const import CONF_SERIAL_PORT, DOMAIN, LOGGER, SIGNAL_EDL21_TELEGRAM +from .const import ( + CONF_SERIAL_PORT, + DEFAULT_DEVICE_NAME, + DOMAIN, + LOGGER, + SIGNAL_EDL21_TELEGRAM, +) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -368,13 +375,16 @@ class EDL21: else: entity_description = SENSORS.get(obis) if entity_description and entity_description.name: - name = entity_description.name - if self._name: - name = f"{self._name}: {name}" - + # self._name is only used for backwards YAML compatibility + # This needs to be cleaned up when YAML support is removed + device_name = self._name or DEFAULT_DEVICE_NAME new_entities.append( EDL21Entity( - electricity_id, obis, name, entity_description, telegram + electricity_id, + obis, + device_name, + entity_description, + telegram, ) ) self._registered_obis.add((electricity_id, obis)) @@ -397,7 +407,7 @@ class EDL21: old_entity_id = registry.async_get_entity_id( "sensor", DOMAIN, entity.old_unique_id ) - if old_entity_id is not None: + if old_entity_id is not None and entity.unique_id is not None: LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity.old_unique_id, @@ -417,13 +427,12 @@ class EDL21Entity(SensorEntity): """Entity reading values from EDL21 telegram.""" _attr_should_poll = False + _attr_has_entity_name = True - def __init__(self, electricity_id, obis, name, entity_description, telegram): + def __init__(self, electricity_id, obis, device_name, entity_description, telegram): """Initialize an EDL21Entity.""" self._electricity_id = electricity_id self._obis = obis - self._name = name - self._unique_id = f"{electricity_id}_{obis}" self._telegram = telegram self._min_time = MIN_TIME_BETWEEN_UPDATES self._last_update = utcnow() @@ -435,6 +444,11 @@ class EDL21Entity(SensorEntity): } self._async_remove_dispatcher = None self.entity_description = entity_description + self._attr_unique_id = f"{electricity_id}_{obis}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._electricity_id)}, + name=device_name, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -466,21 +480,11 @@ class EDL21Entity(SensorEntity): if self._async_remove_dispatcher: self._async_remove_dispatcher() - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - @property def old_unique_id(self) -> str: """Return a less unique ID as used in the first version of edl21.""" return self._obis - @property - def name(self) -> str | None: - """Return a name.""" - return self._name - @property def native_value(self) -> str: """Return the value of the last received telegram.""" From d1734bc0aba0248b8a53621a61e26f8440245a90 Mon Sep 17 00:00:00 2001 From: Kirill Kulakov Date: Thu, 9 Mar 2023 12:15:14 -0600 Subject: [PATCH 0366/1058] Xiaomi Air Purifier S2 illuminance sensor support (#89208) * Add sensor illuminance sensor support xiaomi s2 air purifier * Add sensor illuminance sensor support xiaomi s2 air purifier * fix sorting --- homeassistant/components/xiaomi_miio/sensor.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index bbf2764ceb6f..249774519d08 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -64,6 +64,7 @@ from .const import ( MODEL_AIRPURIFIER_4_LITE_RMA1, MODEL_AIRPURIFIER_4_LITE_RMB1, MODEL_AIRPURIFIER_4_PRO, + MODEL_AIRPURIFIER_MA2, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V2, @@ -467,6 +468,16 @@ PURIFIER_ZA1_SENSORS = ( ATTR_HUMIDITY, ATTR_TEMPERATURE, ) +PURIFIER_MA2_SENSORS = ( + ATTR_FILTER_LIFE_REMAINING, + ATTR_FILTER_USE, + ATTR_HUMIDITY, + ATTR_MOTOR_SPEED, + ATTR_PM25, + ATTR_TEMPERATURE, + ATTR_USE_TIME, + ATTR_ILLUMINANCE, +) PURIFIER_V2_SENSORS = ( ATTR_FILTER_LIFE_REMAINING, ATTR_FILTER_USE, @@ -564,6 +575,7 @@ MODEL_TO_SENSORS_MAP: dict[str, tuple[str, ...]] = { MODEL_AIRPURIFIER_V2: PURIFIER_V2_SENSORS, MODEL_AIRPURIFIER_V3: PURIFIER_V3_SENSORS, MODEL_AIRPURIFIER_ZA1: PURIFIER_ZA1_SENSORS, + MODEL_AIRPURIFIER_MA2: PURIFIER_MA2_SENSORS, MODEL_FAN_V2: FAN_V2_V3_SENSORS, MODEL_FAN_V3: FAN_V2_V3_SENSORS, MODEL_FAN_ZA5: FAN_ZA5_SENSORS, From 7ef1c289bec69e28f5de29ccd437300499d388e9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Mar 2023 19:17:29 +0100 Subject: [PATCH 0367/1058] Fix Dormakaba dKey deadbolt binary sensor (#89447) * Fix Dormakaba dKey deadbolt binary sensor * Spelling --- homeassistant/components/dormakaba_dkey/binary_sensor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index 95e26a3eeb35..e21e35da1e53 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -45,9 +45,10 @@ BINARY_SENSOR_DESCRIPTIONS = ( ), DormakabaDkeyBinarySensorDescription( key="security_locked", - name="Dead bolt", + name="Deadbolt", device_class=BinarySensorDeviceClass.LOCK, - is_on=lambda state: state.unlock_status != UnlockStatus.SECURITY_LOCKED, + is_on=lambda state: state.unlock_status + not in (UnlockStatus.SECURITY_LOCKED, UnlockStatus.UNLOCKED_SECURITY_LOCKED), ), ) From 48fca3bb2727b393d29f2246bc4df98ba8c35ed6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 21:16:52 +0100 Subject: [PATCH 0368/1058] Fix missing debouncer cancel in update coordinator (#89383) * Fix missing debouncer cancel in update coordinator * Improve * Adjust with comment * Adjust again * Simplify PR * Adjust tests to avoid lingering timer * Improve --- homeassistant/helpers/update_coordinator.py | 4 ++++ tests/helpers/test_update_coordinator.py | 14 +++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index e8ca1a1f91d3..e9de580bad17 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -148,6 +148,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): self._unsub_refresh() self._unsub_refresh = None + self._debounced_refresh.async_cancel() + def async_contexts(self) -> Generator[Any, None, None]: """Return all registered contexts.""" yield from ( @@ -163,6 +165,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): if self.config_entry and self.config_entry.pref_disable_polling: return + # We do not cancel the debouncer here. If the refresh interval is shorter + # than the debouncer cooldown, this would cause the debounce to never be called if self._unsub_refresh: self._unsub_refresh() self._unsub_refresh = None diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 6bba27af6548..9f904f080200 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -146,6 +146,9 @@ async def test_request_refresh(crd) -> None: assert crd.data == 1 assert crd.last_update_success is True + # Cleanup to avoid lingering timer + crd._unschedule_refresh() + async def test_request_refresh_no_auto_update(crd_without_update_interval) -> None: """Test request refresh for update coordinator without automatic update.""" @@ -160,6 +163,9 @@ async def test_request_refresh_no_auto_update(crd_without_update_interval) -> No assert crd.data == 1 assert crd.last_update_success is True + # Cleanup to avoid lingering timer + crd._unschedule_refresh() + @pytest.mark.parametrize( "err_msg", @@ -293,7 +299,8 @@ async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[ ) as mock_async_on_remove: await entity.async_added_to_hass() - assert mock_async_on_remove.called + mock_async_on_remove.assert_called_once() + _on_remove_callback = mock_async_on_remove.call_args[0][0] # Verify we do not update if the entity is disabled crd.last_update_success = False @@ -303,6 +310,11 @@ async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[ assert list(crd.async_contexts()) == [context] + # Call remove callback to cleanup debouncer and avoid lingering timer + assert len(crd._listeners) == 1 + _on_remove_callback() + assert len(crd._listeners) == 0 + async def test_async_set_updated_data(crd) -> None: """Test async_set_updated_data for update coordinator.""" From eed16dc185b9b04682b7589e49c084a9aeefab36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Mind=C3=AAllo=20de=20Andrade?= Date: Thu, 9 Mar 2023 18:32:30 -0300 Subject: [PATCH 0369/1058] Add list areas function to template (#88441) --- homeassistant/helpers/template.py | 9 +++++++++ tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index c923bd2d84af..5205d51273fb 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1185,6 +1185,12 @@ def is_device_attr( return bool(device_attr(hass, device_or_entity_id, attr_name) == attr_value) +def areas(hass: HomeAssistant) -> Iterable[str | None]: + """Return all areas.""" + area_reg = area_registry.async_get(hass) + return [area.id for area in area_reg.async_list_areas()] + + def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: """Get the area ID from an area name, device id, or entity id.""" area_reg = area_registry.async_get(hass) @@ -2183,6 +2189,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["device_id"] = hassfunction(device_id) self.filters["device_id"] = pass_context(self.globals["device_id"]) + self.globals["areas"] = hassfunction(areas) + self.filters["areas"] = pass_context(self.globals["areas"]) + self.globals["area_id"] = hassfunction(area_id) self.filters["area_id"] = pass_context(self.globals["area_id"]) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f97f0a4b9c5f..5122a4238ead 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -2851,6 +2851,26 @@ async def test_device_attr( assert info.rate_limit is None +async def test_areas(hass: HomeAssistant, area_registry: ar.AreaRegistry) -> None: + """Test areas function.""" + # Test no areas + info = render_to_info(hass, "{{ areas() }}") + assert_result_info(info, []) + assert info.rate_limit is None + + # Test one area + area1 = area_registry.async_get_or_create("area1") + info = render_to_info(hass, "{{ areas() }}") + assert_result_info(info, [area1.id]) + assert info.rate_limit is None + + # Test multiple areas + area2 = area_registry.async_get_or_create("area2") + info = render_to_info(hass, "{{ areas() }}") + assert_result_info(info, [area1.id, area2.id]) + assert info.rate_limit is None + + async def test_area_id( hass: HomeAssistant, area_registry: ar.AreaRegistry, From f8462fd5b7e8719f034fb532600738c8178d8c3e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 9 Mar 2023 22:57:16 +0100 Subject: [PATCH 0370/1058] Add missing mock in guardian config flow tests (#89451) --- tests/components/guardian/conftest.py | 12 +++++++++++- tests/components/guardian/test_config_flow.py | 3 +++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/components/guardian/conftest.py b/tests/components/guardian/conftest.py index c7bffee4fff0..acf59aeea869 100644 --- a/tests/components/guardian/conftest.py +++ b/tests/components/guardian/conftest.py @@ -1,6 +1,7 @@ """Define fixtures for Elexa Guardian tests.""" +from collections.abc import Generator import json -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -11,6 +12,15 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.guardian.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config, unique_id): """Define a config entry fixture.""" diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index 2fdacdf29a77..cb28ea22a379 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from aioguardian.errors import GuardianError +import pytest from homeassistant import data_entry_flow from homeassistant.components import dhcp, zeroconf @@ -16,6 +17,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_duplicate_error( hass: HomeAssistant, config, config_entry, setup_guardian From 4591bb1823fe947e050e3aaddb7bdd35ce231d59 Mon Sep 17 00:00:00 2001 From: Jared Szechy Date: Thu, 9 Mar 2023 19:13:56 -0500 Subject: [PATCH 0371/1058] Add Ruth and Stephen voices to AWS Polly (#89344) Add Ruth and Stephen --- .../components/amazon_polly/const.py | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/amazon_polly/const.py b/homeassistant/components/amazon_polly/const.py index a0250938fb45..e1f7afce174c 100644 --- a/homeassistant/components/amazon_polly/const.py +++ b/homeassistant/components/amazon_polly/const.py @@ -34,49 +34,49 @@ CONF_TEXT_TYPE: Final = "text_type" SUPPORTED_VOICES: Final[list[str]] = [ "Aditi", # Hindi - "Amy", - "Aria", + "Amy", # English (British) + "Aria", # English (New Zealand), Neural "Arlet", # Catalan, Neural "Arthur", # English, Neural "Astrid", # Swedish - "Ayanda", + "Ayanda", # English (South African), Neural "Bianca", # Italian - "Brian", + "Brian", # English (British) "Camila", # Portuguese, Brazilian - "Carla", + "Carla", # Italian "Carmen", # Romanian - "Celine", + "Celine", # French "Chantal", # French Canadian - "Conchita", - "Cristiano", + "Conchita", # Spanish (European) + "Cristiano", # Portuguese (European) "Daniel", # German, Neural "Dora", # Icelandic "Elin", # Swedish, Neural "Emma", # English - "Enrique", - "Ewa", + "Enrique", # Spanish (European) + "Ewa", # Polish "Filiz", # Turkish - "Gabrielle", + "Gabrielle", # French (Canadian) "Geraint", # English Welsh - "Giorgio", + "Giorgio", # Italian "Gwyneth", # Welsh "Hala", # Arabic (Gulf), Neural "Hannah", # German (Austrian), Neural - "Hans", + "Hans", # German "Hiujin", # Chinese (Cantonese), Neural "Ida", # Norwegian, Neural "Ines", # Portuguese, European - "Ivy", - "Jacek", - "Jan", - "Joanna", - "Joey", - "Justin", + "Ivy", # English + "Jacek", # Polish + "Jan", # Polish + "Joanna", # English + "Joey", # English + "Justin", # English "Kajal", # English (Indian)/Hindi (Bilingual ), Neural - "Karl", - "Kendra", - "Kevin", - "Kimberly", + "Karl", # Icelandic + "Kendra", # English + "Kevin", # English, Neural + "Kimberly", # English "Laura", # Dutch, Neural "Lea", # French "Liam", # Canadian French, Neural @@ -84,12 +84,12 @@ SUPPORTED_VOICES: Final[list[str]] = [ "Lotte", # Dutch "Lucia", # Spanish European "Lupe", # Spanish US - "Mads", + "Mads", # Danish "Maja", # Polish - "Marlene", - "Mathieu", - "Matthew", - "Maxim", + "Marlene", # German + "Mathieu", # French + "Matthew", # English + "Maxim", # Russian "Mia", # Spanish Mexican "Miguel", # Spanish US "Mizuki", # Japanese @@ -100,17 +100,19 @@ SUPPORTED_VOICES: Final[list[str]] = [ "Penelope", # Spanish US "Pedro", # Spanish US, Neural "Raveena", # English, Indian - "Ricardo", - "Ruben", - "Russell", + "Ricardo", # Portuguese (Brazilian) + "Ruben", # Dutch + "Russell", # English (Australian) + "Ruth", # English, Neural "Salli", # English "Seoyeon", # Korean + "Stephen", # English, Neural "Suvi", # Finnish - "Takumi", + "Takumi", # Japanese "Tatyana", # Russian "Vicki", # German "Vitoria", # Portuguese, Brazilian - "Zeina", + "Zeina", # Arabic "Zhiyu", # Chinese ] From d828263ee3e3b813a9d864cf1fc7081360c3a237 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 10 Mar 2023 02:15:22 +0100 Subject: [PATCH 0372/1058] Add device class to ZHA Xiaomi plug "consumer connected" sensor (#89476) Add device class to ZHA Xiaomi plug "consumer connected" --- homeassistant/components/zha/binary_sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index dc5a5eebbaa1..b6a0af8e4597 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -213,3 +213,4 @@ class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): SENSOR_ATTR = "consumer_connected" _attr_name: str = "Consumer connected" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG From 9e1ba8534abfc16c46706ba31c2b1bd2d0dd1c87 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Mar 2023 16:03:41 -1000 Subject: [PATCH 0373/1058] Fix data migration never finishing when database has invalid datetimes (#89474) * Fix data migration never finishing when database has invalid datetimes If there were impossible datetime values in the database (likely from a manual sqlite to MySQL conversion) the conversion would never complete * Update homeassistant/components/recorder/migration.py --- homeassistant/components/recorder/migration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index e0f1163491eb..a5ff110e57ec 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1106,7 +1106,7 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE events set time_fired_ts=" - "IF(time_fired is NULL,0," + "IF(time_fired is NULL or UNIX_TIMESTAMP(time_fired) is NULL,0," "UNIX_TIMESTAMP(time_fired)" ") " "where time_fired_ts is NULL " @@ -1119,7 +1119,7 @@ def _migrate_columns_to_timestamp( result = session.connection().execute( text( "UPDATE states set last_updated_ts=" - "IF(last_updated is NULL,0," + "IF(last_updated is NULL or UNIX_TIMESTAMP(last_updated) is NULL,0," "UNIX_TIMESTAMP(last_updated) " "), " "last_changed_ts=" @@ -1195,7 +1195,7 @@ def _migrate_statistics_columns_to_timestamp( result = session.connection().execute( text( f"UPDATE {table} set start_ts=" - "IF(start is NULL,0," + "IF(start is NULL or UNIX_TIMESTAMP(start) is NULL,0," "UNIX_TIMESTAMP(start) " "), " "created_ts=" From fde205c158b704683650cec1d5832ca0858bbd21 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Mar 2023 04:32:32 +0100 Subject: [PATCH 0374/1058] Add unconfigured flag to thread discovery data (#89230) Co-authored-by: Paulus Schoutsen --- homeassistant/components/thread/discovery.py | 27 ++++- tests/components/thread/__init__.py | 106 ++++++++++++++++++ tests/components/thread/test_discovery.py | 69 +++++++++++- tests/components/thread/test_websocket_api.py | 10 +- 4 files changed, 197 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 5a2ee54c5bb0..b2373ff98258 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -6,6 +6,7 @@ import dataclasses import logging from typing import cast +from python_otbr_api.mdns import StateBitmap from zeroconf import BadTypeInNameException, DNSPointer, ServiceListener, Zeroconf from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf @@ -29,14 +30,15 @@ TYPE_PTR = 12 class ThreadRouterDiscoveryData: """Thread router discovery data.""" + addresses: list[str] | None brand: str | None extended_pan_id: str | None model_name: str | None network_name: str | None server: str | None - vendor_name: str | None - addresses: list[str] | None thread_version: str | None + unconfigured: bool | None + vendor_name: str | None def async_discovery_data_from_service( @@ -59,15 +61,30 @@ def async_discovery_data_from_service( server = service.server vendor_name = try_decode(service.properties.get(b"vn")) thread_version = try_decode(service.properties.get(b"tv")) + unconfigured = None + brand = KNOWN_BRANDS.get(vendor_name) + if brand == "homeassistant": + # Attempt to detect incomplete configuration + if (state_bitmap_b := service.properties.get(b"sb")) is not None: + try: + state_bitmap = StateBitmap.from_bytes(state_bitmap_b) + if not state_bitmap.is_active: + unconfigured = True + except ValueError: + _LOGGER.debug("Failed to decode state bitmap in service %s", service) + if service.properties.get(b"at") is None: + unconfigured = True + return ThreadRouterDiscoveryData( - brand=KNOWN_BRANDS.get(vendor_name), + addresses=service.parsed_addresses(), + brand=brand, extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, model_name=model_name, network_name=network_name, server=server, - vendor_name=vendor_name, - addresses=service.parsed_addresses(), thread_version=thread_version, + unconfigured=unconfigured, + vendor_name=vendor_name, ) diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index c8e4453da1f0..fd3cc3d9d859 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -185,3 +185,109 @@ ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { }, "interface_index": None, } + + +ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP = { + "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"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"pt": b"\x8f\x06Q~", + b"sq": b"3", + b"bb": b"\xf0\xbf", + b"dn": b"DefaultDomain", + }, + "interface_index": None, +} + + +ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP = { + "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"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"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", + }, + "interface_index": None, +} + + +ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP = { + "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"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"\xff\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", + }, + "interface_index": None, +} + + +ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE = { + "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"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\x31", + 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", + }, + "interface_index": None, +} diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index fc19b3f10acb..e832f18c4e68 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -14,8 +14,12 @@ from . import ( ROUTER_DISCOVERY_GOOGLE_1, ROUTER_DISCOVERY_HASS, ROUTER_DISCOVERY_HASS_BAD_DATA, + ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP, ROUTER_DISCOVERY_HASS_MISSING_DATA, ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA, + ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP, + ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP, + ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE, ) @@ -67,14 +71,15 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) assert discovered[-1] == ( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.115"], brand="homeassistant", extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", server="core-silabs-multiprotocol.local.", - vendor_name="HomeAssistant", thread_version="1.3.0", - addresses=["192.168.0.115"], + unconfigured=None, + vendor_name="HomeAssistant", ), ) @@ -91,14 +96,15 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) assert discovered[-1] == ( "f6a99b425a67abed", discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.124"], brand="google", extended_pan_id="9e75e256f61409a3", model_name="Google Nest Hub", network_name="NEST-PAN-E1AF", server="2d99f293-cd8e-2770-8dd2-6675de9fa000.local.", - vendor_name="Google Inc.", thread_version="1.3.0", - addresses=["192.168.0.124"], + unconfigured=None, + vendor_name="Google Inc.", ), ) @@ -130,6 +136,56 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) mock_async_zeroconf.async_remove_service_listener.assert_called_once_with(listener) +@pytest.mark.parametrize( + ("data", "unconfigured"), + [ + (ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP, True), + (ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP, None), + (ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP, None), + (ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE, True), + ], +) +async def test_discover_routers_unconfigured( + hass: HomeAssistant, mock_async_zeroconf: None, data, unconfigured +) -> None: + """Test discovering thread routers with bad or missing vendor mDNS data.""" + mock_async_zeroconf.async_add_service_listener = AsyncMock() + mock_async_zeroconf.async_remove_service_listener = AsyncMock() + mock_async_zeroconf.async_get_service_info = AsyncMock() + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + # Start Thread router discovery + router_discovered_removed = Mock() + thread_disovery = discovery.ThreadRouterDiscovery( + hass, router_discovered_removed, router_discovered_removed + ) + await thread_disovery.async_start() + listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = ( + mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1] + ) + + # Discover a service with bad or missing data + mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(**data) + listener.add_service(None, data["type_"], data["name"]) + await hass.async_block_till_done() + router_discovered_removed.assert_called_once_with( + "aeeb2f594b570bbf", + discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.115"], + brand="homeassistant", + extended_pan_id="e60fc7c186212ce5", + model_name="OpenThreadBorderRouter", + network_name="OpenThread HC", + server="core-silabs-multiprotocol.local.", + thread_version="1.3.0", + unconfigured=unconfigured, + vendor_name="HomeAssistant", + ), + ) + + @pytest.mark.parametrize( "data", (ROUTER_DISCOVERY_HASS_BAD_DATA, ROUTER_DISCOVERY_HASS_MISSING_DATA) ) @@ -161,14 +217,15 @@ async def test_discover_routers_bad_data( router_discovered_removed.assert_called_once_with( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.115"], brand=None, extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", server="core-silabs-multiprotocol.local.", - vendor_name=None, thread_version="1.3.0", - addresses=["192.168.0.115"], + unconfigured=None, + vendor_name=None, ), ) diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 2ebeef92c520..0f3a2ff76548 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -234,14 +234,15 @@ async def test_discover_routers( assert msg == { "event": { "data": { + "addresses": ["192.168.0.115"], "brand": "homeassistant", "extended_pan_id": "e60fc7c186212ce5", "model_name": "OpenThreadBorderRouter", "network_name": "OpenThread HC", "server": "core-silabs-multiprotocol.local.", - "vendor_name": "HomeAssistant", - "addresses": ["192.168.0.115"], "thread_version": "1.3.0", + "unconfigured": None, + "vendor_name": "HomeAssistant", }, "key": "aeeb2f594b570bbf", "type": "router_discovered", @@ -261,14 +262,15 @@ async def test_discover_routers( assert msg == { "event": { "data": { + "addresses": ["192.168.0.124"], "brand": "google", "extended_pan_id": "9e75e256f61409a3", "model_name": "Google Nest Hub", "network_name": "NEST-PAN-E1AF", "server": "2d99f293-cd8e-2770-8dd2-6675de9fa000.local.", - "vendor_name": "Google Inc.", "thread_version": "1.3.0", - "addresses": ["192.168.0.124"], + "unconfigured": None, + "vendor_name": "Google Inc.", }, "key": "f6a99b425a67abed", "type": "router_discovered", From b8bda93d87f35d7862d0014f5a0e876cb2556a42 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Fri, 10 Mar 2023 10:26:03 +0100 Subject: [PATCH 0375/1058] Add config flow to frontier_silicon (#64365) * Add config_flow to frontier_silicon * Add missing translation file * Delay unique_id validation until radio_id can be determined * Fix tests * Improve tests * Use FlowResultType * Bump afsapi to 0.2.6 * Fix requirements_test_all.txt * Stash ssdp, reauth and unignore flows for now * Re-introduce SSDP flow * hassfest changes * Address review comments * Small style update * Fix tests * Update integrations.json * fix order in manifest.json * fix black errors * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Address review comments * fix black errors * Use async_setup_platform instead of async_setup * Address review comments on tests * parameterize tests * Remove discovery component changes from this PR * Address review comments * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add extra asserts to tests * Restructure _async_step_device_config_if_needed * Add return statement * Update homeassistant/components/frontier_silicon/media_player.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 3 +- CODEOWNERS | 1 + .../components/frontier_silicon/__init__.py | 46 ++- .../frontier_silicon/config_flow.py | 178 ++++++++++++ .../components/frontier_silicon/const.py | 3 + .../components/frontier_silicon/manifest.json | 1 + .../frontier_silicon/media_player.py | 57 ++-- .../components/frontier_silicon/strings.json | 35 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/frontier_silicon/__init__.py | 1 + tests/components/frontier_silicon/conftest.py | 59 ++++ .../frontier_silicon/test_config_flow.py | 266 ++++++++++++++++++ 14 files changed, 636 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/frontier_silicon/config_flow.py create mode 100644 homeassistant/components/frontier_silicon/strings.json create mode 100644 tests/components/frontier_silicon/__init__.py create mode 100644 tests/components/frontier_silicon/conftest.py create mode 100644 tests/components/frontier_silicon/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index fa6ae5ba0d22..a533343bf060 100644 --- a/.coveragerc +++ b/.coveragerc @@ -395,7 +395,8 @@ omit = homeassistant/components/fritzbox_callmonitor/__init__.py homeassistant/components/fritzbox_callmonitor/base.py homeassistant/components/fritzbox_callmonitor/sensor.py - homeassistant/components/frontier_silicon/const.py + homeassistant/components/frontier_silicon/__init__.py + homeassistant/components/frontier_silicon/browse_media.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 9020d229fe90..f95d89fec476 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -401,6 +401,7 @@ build.json @home-assistant/supervisor /homeassistant/components/frontend/ @home-assistant/frontend /tests/components/frontend/ @home-assistant/frontend /homeassistant/components/frontier_silicon/ @wlcrs +/tests/components/frontier_silicon/ @wlcrs /homeassistant/components/fully_kiosk/ @cgarwood /tests/components/fully_kiosk/ @cgarwood /homeassistant/components/garages_amsterdam/ @klaasnicolaas diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index ddd74ca8efe5..4a884063f83c 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -1 +1,45 @@ -"""The frontier_silicon component.""" +"""The Frontier Silicon integration.""" +from __future__ import annotations + +import logging + +from afsapi import AFSAPI, ConnectionError as FSConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady + +from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN + +PLATFORMS = [Platform.MEDIA_PLAYER] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Frontier Silicon from a config entry.""" + + webfsapi_url = entry.data[CONF_WEBFSAPI_URL] + pin = entry.data[CONF_PIN] + + afsapi = AFSAPI(webfsapi_url, pin) + + try: + await afsapi.get_power() + except FSConnectionError as exception: + raise PlatformNotReady from exception + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = afsapi + + 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/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py new file mode 100644 index 000000000000..5e9472de62e2 --- /dev/null +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -0,0 +1,178 @@ +"""Config flow for Frontier Silicon Media Player integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException +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 .const import CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } +) + +STEP_DEVICE_CONFIG_DATA_SCHEMA = vol.Schema( + { + vol.Required( + CONF_PIN, + default=DEFAULT_PIN, + ): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Frontier Silicon Media Player.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + + self._webfsapi_url: str | None = None + self._name: str | None = None + self._unique_id: str | None = None + + async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: + """Handle the import of legacy configuration.yaml entries.""" + + device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device" + try: + self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + except FSConnectionError: + return self.async_abort(reason="cannot_connect") + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + return self.async_abort(reason="unknown") + + try: + afsapi = AFSAPI(self._webfsapi_url, import_info[CONF_PIN]) + + self._unique_id = await afsapi.get_radio_id() + except FSConnectionError: + return self.async_abort(reason="cannot_connect") + except InvalidPinException: + return self.async_abort(reason="invalid_auth") + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(self._unique_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + + self._name = import_info[CONF_NAME] or "Radio" + + return await self._create_entry(pin=import_info[CONF_PIN]) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step of manual configuration.""" + errors = {} + + if user_input: + device_url = ( + f"http://{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/device" + ) + try: + self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + except FSConnectionError: + errors["base"] = "cannot_connect" + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + errors["base"] = "unknown" + else: + return await self._async_step_device_config_if_needed() + + data_schema = self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def _async_step_device_config_if_needed(self) -> FlowResult: + """Most users will not have changed the default PIN on their radio. + + We try to use this default PIN, and only if this fails ask for it via `async_step_device_config` + """ + + try: + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) + + self._name = await afsapi.get_friendly_name() + except InvalidPinException: + # Ask for a PIN + return await self.async_step_device_config() + + self.context["title_placeholders"] = {"name": self._name} + + self._unique_id = await afsapi.get_radio_id() + await self.async_set_unique_id(self._unique_id) + self._abort_if_unique_id_configured() + + return await self._create_entry() + + async def async_step_device_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle device configuration step. + + We ask for the PIN in this step. + """ + assert self._webfsapi_url is not None + + if user_input is None: + return self.async_show_form( + step_id="device_config", data_schema=STEP_DEVICE_CONFIG_DATA_SCHEMA + ) + + errors = {} + + try: + afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) + + self._name = await afsapi.get_friendly_name() + + except FSConnectionError: + errors["base"] = "cannot_connect" + except InvalidPinException: + errors["base"] = "invalid_auth" + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + errors["base"] = "unknown" + else: + self._unique_id = await afsapi.get_radio_id() + await self.async_set_unique_id(self._unique_id) + self._abort_if_unique_id_configured() + return await self._create_entry(pin=user_input[CONF_PIN]) + + data_schema = self.add_suggested_values_to_schema( + STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input + ) + return self.async_show_form( + step_id="device_config", + data_schema=data_schema, + errors=errors, + ) + + async def _create_entry(self, pin: str | None = None) -> FlowResult: + """Create the entry.""" + assert self._name is not None + assert self._webfsapi_url is not None + + data = {CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN} + + return self.async_create_entry(title=self._name, data=data) diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 9ee17c0320e1..9206db89166b 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -1,6 +1,9 @@ """Constants for the Frontier Silicon Media Player integration.""" DOMAIN = "frontier_silicon" +CONF_WEBFSAPI_URL = "webfsapi_url" +CONF_PIN = "pin" + DEFAULT_PIN = "1234" DEFAULT_PORT = 80 diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 322c1b90b264..62e7e6170345 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -2,6 +2,7 @@ "domain": "frontier_silicon", "name": "Frontier Silicon", "codeowners": ["@wlcrs"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "iot_class": "local_polling", "requirements": ["afsapi==0.2.7"] diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 0e3eb168484a..b05ba272a19d 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -21,15 +21,17 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .browse_media import browse_node, browse_top_level -from .const import DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET +from .const import CONF_PIN, DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET _LOGGER = logging.getLogger(__name__) @@ -49,7 +51,11 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Frontier Silicon platform.""" + """Set up the Frontier Silicon platform. + + YAML is deprecated, and imported automatically. + SSDP discovery is temporarily retained - to be refactor subsequently. + """ if discovery_info is not None: webfsapi_url = await AFSAPI.get_webfsapi_endpoint( discovery_info["ssdp_description"] @@ -61,24 +67,41 @@ async def async_setup_platform( [AFSAPIDevice(name, afsapi)], True, ) + return - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - password = config.get(CONF_PASSWORD) - name = config.get(CONF_NAME) + ir.async_create_issue( + hass, + DOMAIN, + "remove_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="removed_yaml", + ) - try: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint( - f"http://{host}:{port}/device" - ) - except FSConnectionError: - _LOGGER.error( - "Could not add the FSAPI device at %s:%s -> %s", host, port, password - ) - return - afsapi = AFSAPI(webfsapi_url, password) - async_add_entities([AFSAPIDevice(name, afsapi)], True) + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_NAME: config.get(CONF_NAME), + CONF_HOST: config.get(CONF_HOST), + CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT), + CONF_PIN: config.get(CONF_PASSWORD, DEFAULT_PIN), + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Frontier Silicon entity.""" + + afsapi: AFSAPI = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities([AFSAPIDevice(config_entry.title, afsapi)], True) class AFSAPIDevice(MediaPlayerEntity): diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json new file mode 100644 index 000000000000..85b0b6958afc --- /dev/null +++ b/homeassistant/components/frontier_silicon/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Frontier Silicon Setup", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "device_config": { + "title": "Device Configuration", + "description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'", + "data": { + "pin": "[%key:common::config_flow::data::pin%]" + } + } + }, + "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%]" + } + }, + "issues": { + "removed_yaml": { + "title": "The Frontier Silicon YAML configuration has been removed", + "description": "Configuring Frontier Silicon using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8e13dd971e5c..6656972f8b07 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -145,6 +145,7 @@ FLOWS = { "fritzbox", "fritzbox_callmonitor", "fronius", + "frontier_silicon", "fully_kiosk", "garages_amsterdam", "gdacs", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e8350284f13e..9742af1edfca 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1818,7 +1818,7 @@ "frontier_silicon": { "name": "Frontier Silicon", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "fully_kiosk": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a241892ff52c..1dfb0466be64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -78,6 +78,9 @@ adguardhome==0.6.1 # homeassistant.components.advantage_air advantage_air==0.4.1 +# homeassistant.components.frontier_silicon +afsapi==0.2.7 + # homeassistant.components.agent_dvr agent-py==0.0.23 diff --git a/tests/components/frontier_silicon/__init__.py b/tests/components/frontier_silicon/__init__.py new file mode 100644 index 000000000000..6a039dc29acd --- /dev/null +++ b/tests/components/frontier_silicon/__init__.py @@ -0,0 +1 @@ +"""Tests for the Frontier Silicon integration.""" diff --git a/tests/components/frontier_silicon/conftest.py b/tests/components/frontier_silicon/conftest.py new file mode 100644 index 000000000000..40a6df853106 --- /dev/null +++ b/tests/components/frontier_silicon/conftest.py @@ -0,0 +1,59 @@ +"""Configuration for frontier_silicon tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.frontier_silicon.const import ( + CONF_PIN, + CONF_WEBFSAPI_URL, + DOMAIN, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry() -> MockConfigEntry: + """Create a mock Frontier Silicon config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="mock_radio_id", + data={CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", CONF_PIN: "1234"}, + ) + + +@pytest.fixture(autouse=True) +def mock_valid_device_url() -> Generator[None, None, None]: + """Return a valid webfsapi endpoint.""" + with patch( + "afsapi.AFSAPI.get_webfsapi_endpoint", + return_value="http://1.1.1.1:80/webfsapi", + ): + yield + + +@pytest.fixture(autouse=True) +def mock_valid_pin() -> Generator[None, None, None]: + """Make get_friendly_name return a value, indicating a valid pin.""" + with patch( + "afsapi.AFSAPI.get_friendly_name", + return_value="Name of the device", + ): + yield + + +@pytest.fixture(autouse=True) +def mock_radio_id() -> Generator[None, None, None]: + """Return a valid radio_id.""" + with patch("afsapi.AFSAPI.get_radio_id", return_value="mock_radio_id"): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.frontier_silicon.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py new file mode 100644 index 000000000000..a643b121c74d --- /dev/null +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -0,0 +1,266 @@ +"""Test the Frontier Silicon config flow.""" +from unittest.mock import AsyncMock, patch + +from afsapi import ConnectionError, InvalidPinException +import pytest + +from homeassistant import config_entries +from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_import_success(hass: HomeAssistant) -> None: + """Test successful import.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_PIN: "1234", + CONF_NAME: "Test name", + }, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test name" + assert result["data"] == { + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "1234", + } + + +@pytest.mark.parametrize( + ("webfsapi_endpoint_error", "result_reason"), + [ + (ConnectionError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_import_webfsapi_endpoint_failures( + hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_reason: str +) -> None: + """Test various failure of get_webfsapi_endpoint.""" + with patch( + "afsapi.AFSAPI.get_webfsapi_endpoint", + side_effect=webfsapi_endpoint_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_PIN: "1234", + CONF_NAME: "Test name", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == result_reason + + +@pytest.mark.parametrize( + ("radio_id_error", "result_reason"), + [ + (ConnectionError, "cannot_connect"), + (InvalidPinException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_import_radio_id_failures( + hass: HomeAssistant, radio_id_error: Exception, result_reason: str +) -> None: + """Test various failure of get_radio_id.""" + with patch( + "afsapi.AFSAPI.get_radio_id", + side_effect=radio_id_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_PIN: "1234", + CONF_NAME: "Test name", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == result_reason + + +async def test_import_already_exists( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test import of device which already exists.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + CONF_PIN: "1234", + CONF_NAME: "Test name", + }, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_form_default_pin( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test manual device add with default pin.""" + 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"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Name of the device" + assert result2["data"] == { + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "1234", + } + mock_setup_entry.assert_called_once() + + +async def test_form_nondefault_pin( + 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["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "afsapi.AFSAPI.get_friendly_name", + side_effect=InvalidPinException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result2["errors"] is None + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Name of the device" + assert result3["data"] == { + "webfsapi_url": "http://1.1.1.1:80/webfsapi", + "pin": "4321", + } + mock_setup_entry.assert_called_once() + + +@pytest.mark.parametrize( + ("friendly_name_error", "result_error"), + [ + (ConnectionError, "cannot_connect"), + (InvalidPinException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_form_nondefault_pin_invalid( + hass: HomeAssistant, friendly_name_error: Exception, result_error: str +) -> None: + """Test we get the proper errors when trying to validate an user-provided PIN.""" + 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"] == {} + + with patch( + "afsapi.AFSAPI.get_friendly_name", + side_effect=InvalidPinException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result2["errors"] is None + + with patch( + "afsapi.AFSAPI.get_friendly_name", + side_effect=friendly_name_error, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.FORM + assert result2["step_id"] == "device_config" + assert result3["errors"] == {"base": result_error} + + +@pytest.mark.parametrize( + ("webfsapi_endpoint_error", "result_error"), + [ + (ConnectionError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_invalid_device_url( + hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_error: str +) -> 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["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "afsapi.AFSAPI.get_webfsapi_endpoint", + side_effect=webfsapi_endpoint_error, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": result_error} From a0f725dfcb1b640564f0939e3298b2936da1a930 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 12:06:50 +0100 Subject: [PATCH 0376/1058] Add type hints to tests (#89497) --- tests/components/ecobee/test_climate.py | 8 +++--- tests/components/energyzero/test_sensor.py | 1 - tests/components/hassio/test_ingress.py | 2 +- .../homeassistant_alerts/test_init.py | 2 +- .../homematicip_cloud/test_helpers.py | 2 +- .../components/homematicip_cloud/test_lock.py | 11 ++++++-- tests/components/pjlink/test_media_player.py | 28 +++++++++++-------- tests/components/prosegur/test_camera.py | 21 +++++++++++--- tests/components/prosegur/test_diagnostics.py | 11 ++++++-- tests/components/recorder/test_statistics.py | 8 +++--- tests/components/template/test_cover.py | 2 +- tests/components/tibber/test_config_flow.py | 8 ++++-- tests/components/todoist/test_calendar.py | 4 ++- tests/components/twentemilieu/test_sensor.py | 1 - .../components/universal/test_media_player.py | 4 +-- .../components/weather/test_websocket_api.py | 6 +++- tests/components/zha/test_gateway.py | 4 +-- tests/components/zha/test_registries.py | 4 +-- tests/components/zwave_js/test_api.py | 16 ++++++----- tests/components/zwave_js/test_discovery.py | 8 ++++-- tests/helpers/test_service.py | 16 +++++------ tests/test_core.py | 2 +- tests/util/yaml/test_init.py | 6 ++-- 23 files changed, 109 insertions(+), 66 deletions(-) diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 75722d68c0cb..09b127432db4 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -86,7 +86,7 @@ async def test_name(thermostat) -> None: assert thermostat.name == "Ecobee" -async def test_aux_heat_not_supported_by_default(hass): +async def test_aux_heat_not_supported_by_default(hass: HomeAssistant) -> None: """Default setup should not support Aux heat.""" await setup_platform(hass, const.Platform.CLIMATE) state = hass.states.get(ENTITY_ID) @@ -100,7 +100,7 @@ async def test_aux_heat_not_supported_by_default(hass): ) -async def test_aux_heat_supported_with_heat_pump(hass): +async def test_aux_heat_supported_with_heat_pump(hass: HomeAssistant) -> None: """Aux Heat should be supported if thermostat has heatpump.""" mock_get_thermostat = mock.Mock() mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP @@ -242,7 +242,7 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None: } == thermostat.extra_state_attributes -async def test_is_aux_heat_on(hass): +async def test_is_aux_heat_on(hass: HomeAssistant) -> None: """Test aux heat property is only enabled for auxHeatOnly.""" mock_get_thermostat = mock.Mock() mock_get_thermostat.return_value = copy.deepcopy( @@ -255,7 +255,7 @@ async def test_is_aux_heat_on(hass): assert state.attributes[climate.ATTR_AUX_HEAT] == "on" -async def test_is_aux_heat_off(hass): +async def test_is_aux_heat_off(hass: HomeAssistant) -> None: """Test aux heat property is only enabled for auxHeatOnly.""" mock_get_thermostat = mock.Mock() mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP diff --git a/tests/components/energyzero/test_sensor.py b/tests/components/energyzero/test_sensor.py index 4e961d1a68e0..466e754df278 100644 --- a/tests/components/energyzero/test_sensor.py +++ b/tests/components/energyzero/test_sensor.py @@ -1,5 +1,4 @@ """Tests for the sensors provided by the EnergyZero integration.""" - from unittest.mock import MagicMock from energyzero import EnergyZeroNoDataError diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 67548a19c2c7..06b7523614c8 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -335,7 +335,7 @@ async def test_ingress_missing_peername( async def test_forwarding_paths_as_requested( - hassio_noauth_client, aioclient_mock + hassio_noauth_client, aioclient_mock: AiohttpClientMocker ) -> None: """Test incomnig URLs with double encoding go out as dobule encoded.""" # This double encoded string should be forwarded double-encoded too. diff --git a/tests/components/homeassistant_alerts/test_init.py b/tests/components/homeassistant_alerts/test_init.py index f5e040aa3895..36f0cad75882 100644 --- a/tests/components/homeassistant_alerts/test_init.py +++ b/tests/components/homeassistant_alerts/test_init.py @@ -283,7 +283,7 @@ async def test_alerts( ) async def test_alerts_refreshed_on_component_load( hass: HomeAssistant, - hass_ws_client, + hass_ws_client: WebSocketGenerator, aioclient_mock: AiohttpClientMocker, ha_version, supervisor_info, diff --git a/tests/components/homematicip_cloud/test_helpers.py b/tests/components/homematicip_cloud/test_helpers.py index 85c16255d714..40ce5e536b13 100644 --- a/tests/components/homematicip_cloud/test_helpers.py +++ b/tests/components/homematicip_cloud/test_helpers.py @@ -5,7 +5,7 @@ import json from homeassistant.components.homematicip_cloud.helpers import is_error_response -async def test_is_error_response(): +async def test_is_error_response() -> None: """Test, if an response is a normal result or an error.""" assert not is_error_response("True") assert not is_error_response(True) diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py index 48ae02738a6a..61457fd5119f 100644 --- a/tests/components/homematicip_cloud/test_lock.py +++ b/tests/components/homematicip_cloud/test_lock.py @@ -12,13 +12,14 @@ from homeassistant.components.lock import ( LockEntityFeature, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from .helper import async_manipulate_test_data, get_and_check_entity_basics -async def test_manually_configured_platform(hass): +async def test_manually_configured_platform(hass: HomeAssistant) -> None: """Test that we do not set up an access point.""" assert await async_setup_component( hass, DOMAIN, {DOMAIN: {"platform": HMIPC_DOMAIN}} @@ -26,7 +27,9 @@ async def test_manually_configured_platform(hass): assert not hass.data.get(HMIPC_DOMAIN) -async def test_hmip_doorlockdrive(hass, default_mock_hap_factory): +async def test_hmip_doorlockdrive( + hass: HomeAssistant, default_mock_hap_factory +) -> None: """Test HomematicipDoorLockDrive.""" entity_id = "lock.haustuer" entity_name = "Haustuer" @@ -82,7 +85,9 @@ async def test_hmip_doorlockdrive(hass, default_mock_hap_factory): assert ha_state.state == STATE_UNLOCKING -async def test_hmip_doorlockdrive_handle_errors(hass, default_mock_hap_factory): +async def test_hmip_doorlockdrive_handle_errors( + hass: HomeAssistant, default_mock_hap_factory +) -> None: """Test HomematicipDoorLockDrive.""" entity_id = "lock.haustuer" entity_name = "Haustuer" diff --git a/tests/components/pjlink/test_media_player.py b/tests/components/pjlink/test_media_player.py index c4a923c16eed..686ece5b7ecc 100644 --- a/tests/components/pjlink/test_media_player.py +++ b/tests/components/pjlink/test_media_player.py @@ -1,5 +1,4 @@ """Test the pjlink media player platform.""" - from datetime import timedelta import socket from unittest.mock import create_autospec, patch @@ -11,6 +10,7 @@ import pytest import homeassistant.components.media_player as media_player from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt @@ -48,7 +48,9 @@ def mocked_projector(projector_from_address): @pytest.mark.parametrize("side_effect", [socket.timeout, OSError]) -async def test_offline_initialization(projector_from_address, hass, side_effect): +async def test_offline_initialization( + projector_from_address, hass: HomeAssistant, side_effect +) -> None: """Test initialization of a device that is offline.""" with assert_setup_component(1, media_player.DOMAIN): @@ -71,7 +73,7 @@ async def test_offline_initialization(projector_from_address, hass, side_effect) assert state.state == "unavailable" -async def test_initialization(projector_from_address, hass): +async def test_initialization(projector_from_address, hass: HomeAssistant) -> None: """Test a device that is available.""" with assert_setup_component(1, media_player.DOMAIN): @@ -108,7 +110,9 @@ async def test_initialization(projector_from_address, hass): @pytest.mark.parametrize("power_state", ["on", "warm-up"]) -async def test_on_state_init(projector_from_address, hass, power_state): +async def test_on_state_init( + projector_from_address, hass: HomeAssistant, power_state +) -> None: """Test a device that is available.""" with assert_setup_component(1, media_player.DOMAIN): @@ -139,7 +143,7 @@ async def test_on_state_init(projector_from_address, hass, power_state): assert state.attributes["source"] == "HDMI 1" -async def test_api_error(projector_from_address, hass): +async def test_api_error(projector_from_address, hass: HomeAssistant) -> None: """Test invalid api responses.""" with assert_setup_component(1, media_player.DOMAIN): @@ -171,7 +175,7 @@ async def test_api_error(projector_from_address, hass): assert state.state == "off" -async def test_update_unavailable(projector_from_address, hass): +async def test_update_unavailable(projector_from_address, hass: HomeAssistant) -> None: """Test update to a device that is unavailable.""" with assert_setup_component(1, media_player.DOMAIN): @@ -209,7 +213,7 @@ async def test_update_unavailable(projector_from_address, hass): assert state.state == "unavailable" -async def test_unavailable_time(mocked_projector, hass): +async def test_unavailable_time(mocked_projector, hass: HomeAssistant) -> None: """Test unavailable time projector error.""" assert await async_setup_component( @@ -240,7 +244,7 @@ async def test_unavailable_time(mocked_projector, hass): assert "is_volume_muted" not in state.attributes -async def test_turn_off(mocked_projector, hass): +async def test_turn_off(mocked_projector, hass: HomeAssistant) -> None: """Test turning off beamer.""" assert await async_setup_component( @@ -265,7 +269,7 @@ async def test_turn_off(mocked_projector, hass): mocked_projector.set_power.assert_called_with("off") -async def test_turn_on(mocked_projector, hass): +async def test_turn_on(mocked_projector, hass: HomeAssistant) -> None: """Test turning on beamer.""" assert await async_setup_component( @@ -290,7 +294,7 @@ async def test_turn_on(mocked_projector, hass): mocked_projector.set_power.assert_called_with("on") -async def test_mute(mocked_projector, hass): +async def test_mute(mocked_projector, hass: HomeAssistant) -> None: """Test muting beamer.""" assert await async_setup_component( @@ -315,7 +319,7 @@ async def test_mute(mocked_projector, hass): mocked_projector.set_mute.assert_called_with(MUTE_AUDIO, True) -async def test_unmute(mocked_projector, hass): +async def test_unmute(mocked_projector, hass: HomeAssistant) -> None: """Test unmuting beamer.""" assert await async_setup_component( @@ -340,7 +344,7 @@ async def test_unmute(mocked_projector, hass): mocked_projector.set_mute.assert_called_with(MUTE_AUDIO, False) -async def test_select_source(mocked_projector, hass): +async def test_select_source(mocked_projector, hass: HomeAssistant) -> None: """Test selecting source.""" assert await async_setup_component( diff --git a/tests/components/prosegur/test_camera.py b/tests/components/prosegur/test_camera.py index 40ab57e088b9..ba2e478f5cd4 100644 --- a/tests/components/prosegur/test_camera.py +++ b/tests/components/prosegur/test_camera.py @@ -9,10 +9,11 @@ from homeassistant.components import camera from homeassistant.components.camera import Image from homeassistant.components.prosegur.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -async def test_camera(hass, init_integration): +async def test_camera(hass: HomeAssistant, init_integration) -> None: """Test prosegur get_image.""" image = await camera.async_get_image(hass, "camera.test_cam") @@ -20,7 +21,12 @@ async def test_camera(hass, init_integration): assert image == Image(content_type="image/jpeg", content=b"ABC") -async def test_camera_fail(hass, init_integration, mock_install, caplog): +async def test_camera_fail( + hass: HomeAssistant, + init_integration, + mock_install, + caplog: pytest.LogCaptureFixture, +) -> None: """Test prosegur get_image fails.""" mock_install.get_image = AsyncMock( @@ -37,7 +43,9 @@ async def test_camera_fail(hass, init_integration, mock_install, caplog): assert "Image test_cam doesn't exist" in caplog.text -async def test_request_image(hass, init_integration, mock_install): +async def test_request_image( + hass: HomeAssistant, init_integration, mock_install +) -> None: """Test the camera request image service.""" await hass.services.async_call( @@ -50,7 +58,12 @@ async def test_request_image(hass, init_integration, mock_install): assert mock_install.request_image.called -async def test_request_image_fail(hass, init_integration, mock_install, caplog): +async def test_request_image_fail( + hass: HomeAssistant, + init_integration, + mock_install, + caplog: pytest.LogCaptureFixture, +) -> None: """Test the camera request image service fails.""" mock_install.request_image = AsyncMock(side_effect=ProsegurException()) diff --git a/tests/components/prosegur/test_diagnostics.py b/tests/components/prosegur/test_diagnostics.py index 85377833a74f..daa92de1aa02 100644 --- a/tests/components/prosegur/test_diagnostics.py +++ b/tests/components/prosegur/test_diagnostics.py @@ -1,11 +1,18 @@ """Test Prosegur diagnostics.""" - from unittest.mock import patch +from homeassistant.core import HomeAssistant + from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator -async def test_diagnostics(hass, hass_client, init_integration, mock_install): +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration, + mock_install, +) -> None: """Test generating diagnostics for a config entry.""" with patch( diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index e6ae291264fa..7c064a03edfd 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1805,7 +1805,7 @@ def record_states(hass): return zero, four, states -def test_cache_key_for_generate_statistics_during_period_stmt(): +def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_during_period_stmt( @@ -1835,7 +1835,7 @@ def test_cache_key_for_generate_statistics_during_period_stmt(): assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_get_metadata_stmt(): +def test_cache_key_for_generate_get_metadata_stmt() -> None: """Test cache key for _generate_get_metadata_stmt.""" stmt_mean = _generate_get_metadata_stmt([0], "mean") stmt_mean2 = _generate_get_metadata_stmt([1], "mean") @@ -1846,7 +1846,7 @@ def test_cache_key_for_generate_get_metadata_stmt(): assert stmt_mean._generate_cache_key() != stmt_none._generate_cache_key() -def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt(): +def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt() -> None: """Test cache key for _generate_max_mean_min_statistic_in_sub_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_max_mean_min_statistic_in_sub_period_stmt( @@ -1883,7 +1883,7 @@ def test_cache_key_for_generate_max_mean_min_statistic_in_sub_period_stmt(): assert cache_key_1 != cache_key_3 -def test_cache_key_for_generate_statistics_at_time_stmt(): +def test_cache_key_for_generate_statistics_at_time_stmt() -> None: """Test cache key for _generate_statistics_at_time_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_at_time_stmt(columns, StatisticsShortTerm, {0}, 0.0) diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index acf49eb5469a..adc41fe717b7 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -225,7 +225,7 @@ async def test_template_position(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_template_not_optimistic(hass, start_ha): +async def test_template_not_optimistic(hass: HomeAssistant, start_ha) -> None: """Test the is_closed attribute.""" state = hass.states.get("cover.test_template_cover") assert state.state == STATE_UNKNOWN diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py index e07e4d66cd2e..545a79ff56f1 100644 --- a/tests/components/tibber/test_config_flow.py +++ b/tests/components/tibber/test_config_flow.py @@ -70,7 +70,9 @@ async def test_create_entry(recorder_mock: Recorder, hass: HomeAssistant) -> Non (FatalHttpException(404), ERR_CLIENT), ], ) -async def test_create_entry_exceptions(recorder_mock, hass, exception, expected_error): +async def test_create_entry_exceptions( + recorder_mock: Recorder, hass: HomeAssistant, exception, expected_error +) -> None: """Test create entry from user input.""" test_data = { CONF_ACCESS_TOKEN: "valid", @@ -93,7 +95,9 @@ async def test_create_entry_exceptions(recorder_mock, hass, exception, expected_ assert result["errors"][CONF_ACCESS_TOKEN] == expected_error -async def test_flow_entry_already_exists(recorder_mock, hass, config_entry): +async def test_flow_entry_already_exists( + recorder_mock: Recorder, hass: HomeAssistant, config_entry +) -> None: """Test user input for config_entry that already exists.""" test_data = { CONF_ACCESS_TOKEN: "valid", diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 5b5dc817d6d3..82eff0d75535 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -91,7 +91,9 @@ async def test_calendar_entity_unique_id( @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") -async def test_update_entity_for_custom_project_with_labels_on(todoist_api, hass, api): +async def test_update_entity_for_custom_project_with_labels_on( + todoist_api, hass: HomeAssistant, api +) -> None: """Test that the calendar's state is on for a custom project using labels.""" todoist_api.return_value = api assert await setup.async_setup_component( diff --git a/tests/components/twentemilieu/test_sensor.py b/tests/components/twentemilieu/test_sensor.py index 6fd39e38d487..e4b845264db3 100644 --- a/tests/components/twentemilieu/test_sensor.py +++ b/tests/components/twentemilieu/test_sensor.py @@ -1,5 +1,4 @@ """Tests for the Twente Milieu sensors.""" - import pytest from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 12d7b444097d..fbf4e576dd56 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -1103,7 +1103,7 @@ async def test_state_template(hass: HomeAssistant) -> None: assert hass.states.get("media_player.tv").state == STATE_OFF -async def test_browse_media(hass: HomeAssistant): +async def test_browse_media(hass: HomeAssistant) -> None: """Test browse media.""" await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} @@ -1133,7 +1133,7 @@ async def test_browse_media(hass: HomeAssistant): assert result == MOCK_BROWSE_MEDIA -async def test_browse_media_override(hass: HomeAssistant): +async def test_browse_media_override(hass: HomeAssistant) -> None: """Test browse media override.""" await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py index 1112d7713ed0..760acbb2bb0b 100644 --- a/tests/components/weather/test_websocket_api.py +++ b/tests/components/weather/test_websocket_api.py @@ -3,8 +3,12 @@ from homeassistant.components.weather.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.typing import WebSocketGenerator -async def test_device_class_units(hass: HomeAssistant, hass_ws_client) -> None: + +async def test_device_class_units( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: """Test we can get supported units.""" assert await async_setup_component(hass, DOMAIN, {}) diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index adff43d377be..392c589ea18e 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -306,8 +306,8 @@ async def test_gateway_initialize_failure_transient( ], ) async def test_gateway_initialize_bellows_thread( - device_path, thread_state, config_override, hass, coordinator -): + device_path, thread_state, config_override, hass: HomeAssistant, coordinator +) -> None: """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" zha_gateway = get_zha_gateway(hass) assert zha_gateway is not None diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 24cd7a5785fd..6a6bf758cebc 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -323,7 +323,7 @@ def test_weighted_match( model, quirk_class, match_name, -): +) -> None: """Test weightedd match.""" s = mock.sentinel @@ -435,7 +435,7 @@ def test_multi_sensor_match(channel, entity_registry: er.EntityRegistry) -> None } -def test_quirk_classes(): +def test_quirk_classes() -> None: """Make sure that quirk_classes in components matches are valid.""" def find_quirk_class(base_obj, quirk_mod, quirk_cls): diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 4e99d19261b0..43489be4ccf7 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1460,8 +1460,8 @@ async def test_parse_qr_code_string( async def test_try_parse_dsk_from_qr_code_string( - hass, integration, client, hass_ws_client -): + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test try_parse_dsk_from_qr_code_string websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -1524,7 +1524,9 @@ async def test_try_parse_dsk_from_qr_code_string( assert msg["error"]["code"] == ERR_NOT_LOADED -async def test_supports_feature(hass, integration, client, hass_ws_client): +async def test_supports_feature( + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test supports_feature websocket command.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3888,8 +3890,8 @@ async def test_subscribe_firmware_update_status_initial_value( async def test_subscribe_controller_firmware_update_status( - hass, integration, client, hass_ws_client -): + hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator +) -> None: """Test the subscribe_firmware_update_status websocket command for a node.""" ws_client = await hass_ws_client(hass) device = get_device(hass, client.driver.controller.nodes[1]) @@ -3954,8 +3956,8 @@ async def test_subscribe_controller_firmware_update_status( async def test_subscribe_controller_firmware_update_status_initial_value( - hass, client, integration, hass_ws_client -): + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: """Test subscribe_firmware_update_status cmd with in progress update for node.""" ws_client = await hass_ws_client(hass) device = get_device(hass, client.driver.controller.nodes[1]) diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 66969c51ff01..1840e4d79800 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -103,7 +103,9 @@ async def test_dynamic_climate_data_discovery_template_failure( ) -async def test_merten_507801(hass, client, merten_507801, integration): +async def test_merten_507801( + hass: HomeAssistant, client, merten_507801, integration +) -> None: """Test that Merten 507801 multilevel switch value is discovered as a cover.""" node = merten_507801 assert node.device_class.specific.label == "Unused" @@ -116,8 +118,8 @@ async def test_merten_507801(hass, client, merten_507801, integration): async def test_merten_507801_disabled_enitites( - hass, client, merten_507801, integration -): + hass: HomeAssistant, client, merten_507801, integration +) -> None: """Test that Merten 507801 entities created by endpoint 2 are disabled.""" registry = er.async_get(hass) entity_ids = [ diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 43bbf85b06cf..ff86f9c7e766 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -223,7 +223,7 @@ def area_mock(hass): ) -async def test_call_from_config(hass: HomeAssistant): +async def test_call_from_config(hass: HomeAssistant) -> None: """Test the sync wrapper of service.async_call_from_config.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -238,7 +238,7 @@ async def test_call_from_config(hass: HomeAssistant): assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} -async def test_service_call(hass: HomeAssistant): +async def test_service_call(hass: HomeAssistant) -> None: """Test service call with templating.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -307,7 +307,7 @@ async def test_service_call(hass: HomeAssistant): } -async def test_service_template_service_call(hass: HomeAssistant): +async def test_service_template_service_call(hass: HomeAssistant) -> None: """Test legacy service_template call with templating.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -322,7 +322,7 @@ async def test_service_template_service_call(hass: HomeAssistant): assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} -async def test_passing_variables_to_templates(hass: HomeAssistant): +async def test_passing_variables_to_templates(hass: HomeAssistant) -> None: """Test passing variables to templates.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -344,7 +344,7 @@ async def test_passing_variables_to_templates(hass: HomeAssistant): assert calls[0].data == {"hello": "goodbye", "entity_id": ["hello.world"]} -async def test_bad_template(hass: HomeAssistant): +async def test_bad_template(hass: HomeAssistant) -> None: """Test passing bad template.""" calls = async_mock_service(hass, "test_domain", "test_service") config = { @@ -366,7 +366,7 @@ async def test_bad_template(hass: HomeAssistant): assert len(calls) == 0 -async def test_split_entity_string(hass: HomeAssistant): +async def test_split_entity_string(hass: HomeAssistant) -> None: """Test splitting of entity string.""" calls = async_mock_service(hass, "test_domain", "test_service") await service.async_call_from_config( @@ -380,7 +380,7 @@ async def test_split_entity_string(hass: HomeAssistant): assert ["hello.world", "sensor.beer"] == calls[-1].data.get("entity_id") -async def test_not_mutate_input(hass: HomeAssistant): +async def test_not_mutate_input(hass: HomeAssistant) -> None: """Test for immutable input.""" async_mock_service(hass, "test_domain", "test_service") config = { @@ -403,7 +403,7 @@ async def test_not_mutate_input(hass: HomeAssistant): @patch("homeassistant.helpers.service._LOGGER.error") -async def test_fail_silently_if_no_service(mock_log, hass: HomeAssistant): +async def test_fail_silently_if_no_service(mock_log, hass: HomeAssistant) -> None: """Test failing if service is missing.""" await service.async_call_from_config(hass, None) assert mock_log.call_count == 1 diff --git a/tests/test_core.py b/tests/test_core.py index 6d67376b4188..6167bf6a63b7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -75,7 +75,7 @@ def test_async_add_hass_job_schedule_callback() -> None: assert len(hass.add_job.mock_calls) == 0 -def test_async_add_hass_job_coro_named(hass) -> None: +def test_async_add_hass_job_coro_named(hass: HomeAssistant) -> None: """Test that we schedule coroutines and add jobs to the job pool with a name.""" async def mycoro(): diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 28ccdcc58930..bd99889234f0 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -492,7 +492,9 @@ def test_representing_yaml_loaded_data( @pytest.mark.parametrize("hass_config_yaml", ["key: thing1\nkey: thing2"]) -def test_duplicate_key(caplog, try_both_loaders, mock_hass_config_yaml: None) -> None: +def test_duplicate_key( + caplog: pytest.LogCaptureFixture, try_both_loaders, mock_hass_config_yaml: None +) -> None: """Test duplicate dict keys.""" load_yaml_config_file(YAML_CONFIG_FILE) assert "contains duplicate key" in caplog.text @@ -503,7 +505,7 @@ def test_duplicate_key(caplog, try_both_loaders, mock_hass_config_yaml: None) -> [{YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"}], ) def test_no_recursive_secrets( - caplog, try_both_loaders, mock_hass_config_yaml: None + caplog: pytest.LogCaptureFixture, try_both_loaders, mock_hass_config_yaml: None ) -> None: """Test that loading of secrets from the secrets file fails correctly.""" with pytest.raises(HomeAssistantError) as e: From 0f15f8b84b51121f4f63136d6da86e426f80fe96 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 12:58:29 +0100 Subject: [PATCH 0377/1058] Bump pytest-sugar to 0.9.6 (#89500) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 10ceb81365de..51ce5c7d123f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -24,7 +24,7 @@ pytest-cov==3.0.0 pytest-freezer==0.4.6 pytest-socket==0.5.1 pytest-test-groups==1.0.3 -pytest-sugar==0.9.5 +pytest-sugar==0.9.6 pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 From b4c1c0beb7535e99cf05ee146860f3262917322b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 13:08:45 +0100 Subject: [PATCH 0378/1058] Bump pytest-xdist to 3.2.0 (#89501) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 51ce5c7d123f..7601326cd56f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,7 +28,7 @@ pytest-sugar==0.9.6 pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 -pytest-xdist==2.5.0 +pytest-xdist==3.2.0 pytest==7.2.2 requests_mock==1.10.0 respx==0.20.1 From f4b8598979219038f98593faf0a2f09b7aad8889 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Mar 2023 13:27:07 +0100 Subject: [PATCH 0379/1058] Bump home-assistant/builder from 2022.11.0 to 2023.03.0 (#89485) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index cc87e4708c7c..45774642a5ce 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -198,7 +198,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.11.0 + uses: home-assistant/builder@2023.03.0 with: args: | $BUILD_ARGS \ @@ -276,7 +276,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.11.0 + uses: home-assistant/builder@2023.03.0 with: args: | $BUILD_ARGS \ From 029093d0b27ce6f69f131605140e61c1ce35ce78 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 15:48:58 +0100 Subject: [PATCH 0380/1058] Fix lingering timer in device registry (#89422) --- homeassistant/helpers/device_registry.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9ea44db16d54..b72a18786514 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast import attr from homeassistant.backports.enum import StrEnum -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, RequiredParameterMissing from homeassistant.loader import bind_hass @@ -907,6 +907,13 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean) + @callback + def _on_homeassistant_stop(event: Event) -> None: + """Cancel debounced cleanup.""" + debounced_cleanup.async_cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop) + def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, str]]: """Normalize connections to ensure we can match mac addresses.""" From 75bca76e6864b724854fd04e79fc8a3365319d92 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Fri, 10 Mar 2023 15:57:35 +0100 Subject: [PATCH 0381/1058] Landis+Gyr move coordinator to own file (#89433) * Move coordinator to own file and add test cases * Apply typing improvements from review * Remove testcase for exception during setup * Simplify unittest for failing serial connection * Readd checks in serial connection test after review --- .../landisgyr_heat_meter/__init__.py | 18 +--- .../components/landisgyr_heat_meter/const.py | 3 + .../landisgyr_heat_meter/coordinator.py | 37 +++++++ .../landisgyr_heat_meter/test_sensor.py | 96 ++++++++++++++++--- 4 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 homeassistant/components/landisgyr_heat_meter/coordinator.py diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index eae5e91196cc..541fef017d01 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -1,19 +1,17 @@ """The Landis+Gyr Heat Meter integration.""" from __future__ import annotations -from datetime import timedelta import logging import ultraheat_api -from ultraheat_api.response import HeatMeterResponse from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_registry import async_migrate_entries -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import UltraheatCoordinator _LOGGER = logging.getLogger(__name__) @@ -27,19 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: reader = ultraheat_api.UltraheatReader(entry.data[CONF_DEVICE]) api = ultraheat_api.HeatMeterService(reader) - async def async_update_data() -> HeatMeterResponse: - """Fetch data from the API.""" - _LOGGER.debug("Polling on %s", entry.data[CONF_DEVICE]) - return await hass.async_add_executor_job(api.read) - - # Polling is only daily to prevent battery drain. - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="ultraheat_gateway", - update_method=async_update_data, - update_interval=timedelta(days=1), - ) + coordinator = UltraheatCoordinator(hass, api) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index 5d27a8a17052..56f5980a839d 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -1,6 +1,9 @@ """Constants for the Landis+Gyr Heat Meter integration.""" +from datetime import timedelta + DOMAIN = "landisgyr_heat_meter" GJ_TO_MWH = 0.277778 # conversion factor ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time +POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain. diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py new file mode 100644 index 000000000000..c85c661e79c3 --- /dev/null +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -0,0 +1,37 @@ +"""Data update coordinator for the ultraheat api.""" + +import logging + +import async_timeout +import serial +from ultraheat_api.response import HeatMeterResponse +from ultraheat_api.service import HeatMeterService + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import POLLING_INTERVAL, ULTRAHEAT_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): + """Coordinator for getting data from the ultraheat api.""" + + def __init__(self, hass: HomeAssistant, api: HeatMeterService) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="ultraheat", + update_interval=POLLING_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> HeatMeterResponse: + """Fetch data from API endpoint.""" + try: + async with async_timeout.timeout(ULTRAHEAT_TIMEOUT): + return await self.hass.async_add_executor_job(self.api.read) + except (FileNotFoundError, serial.serialutil.SerialException) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 9a94491a94fe..854ead82b3db 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -3,11 +3,13 @@ from dataclasses import dataclass import datetime from unittest.mock import patch +import serial + from homeassistant.components.homeassistant import ( DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) -from homeassistant.components.landisgyr_heat_meter.const import DOMAIN +from homeassistant.components.landisgyr_heat_meter.const import DOMAIN, POLLING_INTERVAL from homeassistant.components.sensor import ( ATTR_LAST_RESET, ATTR_STATE_CLASS, @@ -19,6 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, EntityCategory, UnitOfEnergy, UnitOfVolume, @@ -28,21 +31,29 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, mock_restore_cache_with_extra_data +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache_with_extra_data, +) + +API_HEAT_METER_SERVICE = ( + "homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService" +) @dataclass class MockHeatMeterResponse: """Mock for HeatMeterResponse.""" - heat_usage_gj: int - volume_usage_m3: int - heat_previous_year_gj: int + heat_usage_gj: float + volume_usage_m3: float + heat_previous_year_gj: float device_number: str meter_date_time: datetime.datetime -@patch("homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService") +@patch(API_HEAT_METER_SERVICE) async def test_create_sensors( mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -57,9 +68,9 @@ async def test_create_sensors( mock_entry.add_to_hass(hass) mock_heat_meter_response = MockHeatMeterResponse( - heat_usage_gj=123, - volume_usage_m3=456, - heat_previous_year_gj=111, + heat_usage_gj=123.0, + volume_usage_m3=456.0, + heat_previous_year_gj=111.0, device_number="devicenr_789", meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), ) @@ -89,7 +100,7 @@ async def test_create_sensors( state = hass.states.get("sensor.heat_meter_volume_usage") assert state - assert state.state == "456" + assert state.state == "456.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL @@ -110,7 +121,7 @@ async def test_create_sensors( assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC -@patch("homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService") +@patch(API_HEAT_METER_SERVICE) async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: """Test sensor restore state.""" # Home assistant is not running yet @@ -199,3 +210,66 @@ async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: assert state assert state.state == "devicenr_789" assert state.attributes.get(ATTR_STATE_CLASS) is None + + +@patch(API_HEAT_METER_SERVICE) +async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> None: + """Test sensor.""" + entry_data = { + "device": "/dev/USB0", + "model": "LUGCUH50", + "device_number": "123456789", + } + mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) + mock_entry.add_to_hass(hass) + + # First setup normally + mock_heat_meter_response = MockHeatMeterResponse( + heat_usage_gj=123.0, + volume_usage_m3=456.0, + heat_previous_year_gj=111.0, + device_number="devicenr_789", + meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 19, 41, 17)), + ) + + mock_heat_meter().read.return_value = mock_heat_meter_response + + await hass.config_entries.async_setup(mock_entry.entry_id) + await async_setup_component(hass, HA_DOMAIN, {}) + await hass.async_block_till_done() + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage"}, + blocking=True, + ) + await hass.async_block_till_done() + + # check if initial setup succeeded + state = hass.states.get("sensor.heat_meter_heat_usage") + assert state + assert state.state == "34.16669" + + # Now 'disable' the connection and wait for polling and see if it fails + mock_heat_meter().read.side_effect = serial.serialutil.SerialException + async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) + await hass.async_block_till_done() + state = hass.states.get("sensor.heat_meter_heat_usage") + assert state.state == STATE_UNAVAILABLE + + # Now 'enable' and see if next poll succeeds + mock_heat_meter_response = MockHeatMeterResponse( + heat_usage_gj=124.0, + volume_usage_m3=457.0, + heat_previous_year_gj=112.0, + device_number="devicenr_789", + meter_date_time=dt_util.as_utc(datetime.datetime(2022, 5, 19, 20, 41, 17)), + ) + + mock_heat_meter().read.return_value = mock_heat_meter_response + mock_heat_meter().read.side_effect = None + async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) + await hass.async_block_till_done() + state = hass.states.get("sensor.heat_meter_heat_usage") + assert state + assert state.state == "34.44447" From f674559a71e0f7cee474f594bbe3d9c4c2288454 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:04:45 +0100 Subject: [PATCH 0382/1058] Add missing mock in landisgyr config flow tests (#89513) --- .../landisgyr_heat_meter/conftest.py | 15 ++++++++++++ .../landisgyr_heat_meter/test_config_flow.py | 23 ++++++++----------- 2 files changed, 24 insertions(+), 14 deletions(-) create mode 100644 tests/components/landisgyr_heat_meter/conftest.py diff --git a/tests/components/landisgyr_heat_meter/conftest.py b/tests/components/landisgyr_heat_meter/conftest.py new file mode 100644 index 000000000000..711fa2110f43 --- /dev/null +++ b/tests/components/landisgyr_heat_meter/conftest.py @@ -0,0 +1,15 @@ +"""Define fixtures for Landis + Gyr Heat Meter 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.landisgyr_heat_meter.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index 576388686477..b58c91f8f16a 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from unittest.mock import patch +import pytest import serial import serial.tools.list_ports @@ -14,6 +15,8 @@ from tests.common import MockConfigEntry API_HEAT_METER_SERVICE = "homeassistant.components.landisgyr_heat_meter.config_flow.ultraheat_api.HeatMeterService" +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + def mock_serial_port(): """Mock of a serial port.""" @@ -57,13 +60,9 @@ async def test_manual_entry(mock_heat_meter, hass: HomeAssistant) -> None: assert result["step_id"] == "setup_serial_manual_path" assert result["errors"] == {} - with patch( - "homeassistant.components.landisgyr_heat_meter.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"device": "/dev/ttyUSB0"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": "/dev/ttyUSB0"} + ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "LUGCUH50" @@ -122,13 +121,9 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: assert result["step_id"] == "setup_serial_manual_path" assert result["errors"] == {} - with patch( - "homeassistant.components.landisgyr_heat_meter.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"device": "/dev/ttyUSB0"} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device": "/dev/ttyUSB0"} + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "setup_serial_manual_path" From 401273dcfffb44648cf05b2f2c2a44dd39683ccf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:05:13 +0100 Subject: [PATCH 0383/1058] Add missing mock in lacrosse_view config flow tests (#89512) --- tests/components/lacrosse_view/conftest.py | 14 ++++++ .../lacrosse_view/test_config_flow.py | 49 +++++++++---------- 2 files changed, 36 insertions(+), 27 deletions(-) create mode 100644 tests/components/lacrosse_view/conftest.py diff --git a/tests/components/lacrosse_view/conftest.py b/tests/components/lacrosse_view/conftest.py new file mode 100644 index 000000000000..1ea3144e4c21 --- /dev/null +++ b/tests/components/lacrosse_view/conftest.py @@ -0,0 +1,14 @@ +"""Define fixtures for LaCrosse View 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.lacrosse_view.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/lacrosse_view/test_config_flow.py b/tests/components/lacrosse_view/test_config_flow.py index 67c9bf5752c7..075aa7a37674 100644 --- a/tests/components/lacrosse_view/test_config_flow.py +++ b/tests/components/lacrosse_view/test_config_flow.py @@ -1,7 +1,8 @@ """Test the LaCrosse View config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from lacrosse_view import Location, LoginError +import pytest from homeassistant import config_entries from homeassistant.components.lacrosse_view.const import DOMAIN @@ -10,8 +11,10 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: + +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} @@ -25,8 +28,6 @@ async def test_form(hass: HomeAssistant) -> None: ), patch( "lacrosse_view.LaCrosse.get_locations", return_value=[Location(id=1, name="Test")], - ), patch( - "homeassistant.components.lacrosse_view.async_setup_entry", return_value=True ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -41,17 +42,13 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["step_id"] == "location" assert result2["errors"] is None - with patch( - "homeassistant.components.lacrosse_view.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - "location": "1", - }, - ) - await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "location": "1", + }, + ) + await hass.async_block_till_done() assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Test" @@ -170,7 +167,9 @@ async def test_form_unexpected_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_already_configured_device(hass: HomeAssistant) -> None: +async def test_already_configured_device( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle invalid auth.""" mock_config_entry = MockConfigEntry( domain=DOMAIN, @@ -212,17 +211,13 @@ async def test_already_configured_device(hass: HomeAssistant) -> None: assert result2["step_id"] == "location" assert result2["errors"] is None - with patch( - "homeassistant.components.lacrosse_view.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - "location": "1", - }, - ) - await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "location": "1", + }, + ) + await hass.async_block_till_done() assert result3["type"] == FlowResultType.ABORT assert result3["reason"] == "already_configured" From f22fabdd7f9baa336f378b9086e5956e765a8bcb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:05:31 +0100 Subject: [PATCH 0384/1058] Add missing mock in kmtronic config flow tests (#89511) --- tests/components/kmtronic/conftest.py | 14 ++++++++++++++ tests/components/kmtronic/test_config_flow.py | 12 ++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 tests/components/kmtronic/conftest.py diff --git a/tests/components/kmtronic/conftest.py b/tests/components/kmtronic/conftest.py new file mode 100644 index 000000000000..4310f99242e4 --- /dev/null +++ b/tests/components/kmtronic/conftest.py @@ -0,0 +1,14 @@ +"""Define fixtures for kmtronic 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.kmtronic.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/kmtronic/test_config_flow.py b/tests/components/kmtronic/test_config_flow.py index 37a77a000000..76d11b8451e6 100644 --- a/tests/components/kmtronic/test_config_flow.py +++ b/tests/components/kmtronic/test_config_flow.py @@ -1,8 +1,9 @@ """Test the kmtronic config flow.""" from http import HTTPStatus -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientConnectorError, ClientResponseError +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.kmtronic.const import CONF_REVERSE, DOMAIN @@ -12,8 +13,10 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form(hass: HomeAssistant) -> None: + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -25,10 +28,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.kmtronic.config_flow.KMTronicHubAPI.async_get_status", return_value=[Mock()], - ), patch( - "homeassistant.components.kmtronic.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { From 74d4a26f9776cb218f3ff2d4c383fed51783dee2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:06:53 +0100 Subject: [PATCH 0385/1058] Add missing mock in jellyfin config flow tests (#89510) --- tests/components/jellyfin/test_config_flow.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index 015d44722e08..51aa4bccc921 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -1,6 +1,8 @@ """Test the jellyfin config flow.""" from unittest.mock import MagicMock +import pytest + from homeassistant import config_entries, data_entry_flow from homeassistant.components.jellyfin.const import CONF_CLIENT_DEVICE_ID, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -11,6 +13,8 @@ from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: """Check flow abort when an entry already exist.""" From 96bd7143643a813689c86f6f7512008c187a4766 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:09:04 +0100 Subject: [PATCH 0386/1058] Add FTTH and WAN info to SFR box diagnostics (#89492) * Add FTTH and WAN info to SFR box diagnostics * Adjust tests * Use snapshots --- .../components/sfr_box/diagnostics.py | 6 ++ tests/components/sfr_box/conftest.py | 46 ++++++++++---- .../sfr_box/fixtures/ftth_getInfo.json | 4 ++ .../sfr_box/fixtures/wan_getInfo.json | 11 ++++ .../sfr_box/snapshots/test_diagnostics.ambr | 60 +++++++++++++++++++ tests/components/sfr_box/test_diagnostics.py | 55 ++++------------- 6 files changed, 127 insertions(+), 55 deletions(-) create mode 100644 tests/components/sfr_box/fixtures/ftth_getInfo.json create mode 100644 tests/components/sfr_box/fixtures/wan_getInfo.json create mode 100644 tests/components/sfr_box/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index 6a7ceb0e86b7..60df21739685 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -27,8 +27,14 @@ async def async_get_config_entry_diagnostics( }, "data": { "dsl": async_redact_data(dataclasses.asdict(data.dsl.data), TO_REDACT), + "ftth": async_redact_data( + dataclasses.asdict(await data.system.box.ftth_get_info()), TO_REDACT + ), "system": async_redact_data( dataclasses.asdict(data.system.data), TO_REDACT ), + "wan": async_redact_data( + dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT + ), }, } diff --git a/tests/components/sfr_box/conftest.py b/tests/components/sfr_box/conftest.py index 1857ffeec303..a8cd6fd8bd4d 100644 --- a/tests/components/sfr_box/conftest.py +++ b/tests/components/sfr_box/conftest.py @@ -4,7 +4,7 @@ import json from unittest.mock import AsyncMock, patch import pytest -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo from homeassistant.components.sfr_box.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntry @@ -57,17 +57,6 @@ def get_config_entry_with_auth(hass: HomeAssistant) -> ConfigEntry: return config_entry_with_auth -@pytest.fixture -def system_get_info() -> Generator[SystemInfo, None, None]: - """Fixture for SFRBox.system_get_info.""" - system_info = SystemInfo(**json.loads(load_fixture("system_getInfo.json", DOMAIN))) - with patch( - "homeassistant.components.sfr_box.coordinator.SFRBox.system_get_info", - return_value=system_info, - ): - yield system_info - - @pytest.fixture def dsl_get_info() -> Generator[DslInfo, None, None]: """Fixture for SFRBox.dsl_get_info.""" @@ -77,3 +66,36 @@ def dsl_get_info() -> Generator[DslInfo, None, None]: return_value=dsl_info, ): yield dsl_info + + +@pytest.fixture +def ftth_get_info() -> Generator[FtthInfo, None, None]: + """Fixture for SFRBox.ftth_get_info.""" + info = FtthInfo(**json.loads(load_fixture("ftth_getInfo.json", DOMAIN))) + with patch( + "homeassistant.components.sfr_box.coordinator.SFRBox.ftth_get_info", + return_value=info, + ): + yield info + + +@pytest.fixture +def system_get_info() -> Generator[SystemInfo, None, None]: + """Fixture for SFRBox.system_get_info.""" + info = SystemInfo(**json.loads(load_fixture("system_getInfo.json", DOMAIN))) + with patch( + "homeassistant.components.sfr_box.coordinator.SFRBox.system_get_info", + return_value=info, + ): + yield info + + +@pytest.fixture +def wan_get_info() -> Generator[WanInfo, None, None]: + """Fixture for SFRBox.wan_get_info.""" + info = WanInfo(**json.loads(load_fixture("wan_getInfo.json", DOMAIN))) + with patch( + "homeassistant.components.sfr_box.coordinator.SFRBox.wan_get_info", + return_value=info, + ): + yield info diff --git a/tests/components/sfr_box/fixtures/ftth_getInfo.json b/tests/components/sfr_box/fixtures/ftth_getInfo.json new file mode 100644 index 000000000000..32f720e91117 --- /dev/null +++ b/tests/components/sfr_box/fixtures/ftth_getInfo.json @@ -0,0 +1,4 @@ +{ + "status": "down", + "wanfibre": "out" +} diff --git a/tests/components/sfr_box/fixtures/wan_getInfo.json b/tests/components/sfr_box/fixtures/wan_getInfo.json new file mode 100644 index 000000000000..fdef6270f35d --- /dev/null +++ b/tests/components/sfr_box/fixtures/wan_getInfo.json @@ -0,0 +1,11 @@ +{ + "status": "up", + "uptime": 297464, + "ip_addr": "1.2.3.4", + "infra": "adsl", + "mode": "adsl/routed", + "infra6": "", + "status6": "down", + "uptime6": null, + "ipv6_addr": "" +} diff --git a/tests/components/sfr_box/snapshots/test_diagnostics.ambr b/tests/components/sfr_box/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..2e25259268d6 --- /dev/null +++ b/tests/components/sfr_box/snapshots/test_diagnostics.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'dsl': dict({ + 'attenuation_down': 28.5, + 'attenuation_up': 20.8, + 'counter': 16, + 'crc': 0, + 'line_status': 'No Defect', + 'linemode': 'ADSL2+', + 'noise_down': 5.8, + 'noise_up': 6.0, + 'rate_down': 5549, + 'rate_up': 187, + 'status': 'up', + 'training': 'Showtime', + 'uptime': 450796, + }), + 'ftth': dict({ + 'status': 'down', + 'wanfibre': 'out', + }), + 'system': dict({ + 'alimvoltage': 12251, + 'current_datetime': '202212282233', + 'idur': 'RP3P85K', + 'mac_addr': '**REDACTED**', + 'net_infra': 'adsl', + 'net_mode': 'router', + 'product_id': 'NB6VAC-FXC-r0', + 'refclient': '', + 'serial_number': '**REDACTED**', + 'temperature': 27560, + 'uptime': 2353575, + 'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8', + 'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p', + 'version_mainfirmware': 'NB6VAC-MAIN-R4.0.44k', + 'version_rescuefirmware': 'NB6VAC-MAIN-R4.0.44k', + }), + 'wan': dict({ + 'infra': 'adsl', + 'infra6': '', + 'ip_addr': '1.2.3.4', + 'ipv6_addr': '', + 'mode': 'adsl/routed', + 'status': 'up', + 'status6': 'down', + 'uptime': 297464, + 'uptime6': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '192.168.0.1', + }), + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/sfr_box/test_diagnostics.py b/tests/components/sfr_box/test_diagnostics.py index 966a038b4899..37e3ba9487f0 100644 --- a/tests/components/sfr_box/test_diagnostics.py +++ b/tests/components/sfr_box/test_diagnostics.py @@ -3,15 +3,17 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") +pytestmark = pytest.mark.usefixtures( + "dsl_get_info", "ftth_get_info", "system_get_info", "wan_get_info" +) @pytest.fixture(autouse=True) @@ -22,49 +24,16 @@ def override_platforms() -> Generator[None, None, None]: async def test_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, hass_client: ClientSessionGenerator + hass: HomeAssistant, + config_entry: ConfigEntry, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "data": {"host": "192.168.0.1"}, - "title": "Mock Title", - }, - "data": { - "dsl": { - "attenuation_down": 28.5, - "attenuation_up": 20.8, - "counter": 16, - "crc": 0, - "line_status": "No Defect", - "linemode": "ADSL2+", - "noise_down": 5.8, - "noise_up": 6.0, - "rate_down": 5549, - "rate_up": 187, - "status": "up", - "training": "Showtime", - "uptime": 450796, - }, - "system": { - "alimvoltage": 12251, - "current_datetime": "202212282233", - "idur": "RP3P85K", - "mac_addr": REDACTED, - "net_infra": "adsl", - "net_mode": "router", - "product_id": "NB6VAC-FXC-r0", - "refclient": "", - "serial_number": REDACTED, - "temperature": 27560, - "uptime": 2353575, - "version_bootloader": "NB6VAC-BOOTLOADER-R4.0.8", - "version_dsldriver": "NB6VAC-XDSL-A2pv6F039p", - "version_mainfirmware": "NB6VAC-MAIN-R4.0.44k", - "version_rescuefirmware": "NB6VAC-MAIN-R4.0.44k", - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From d6a223f0e161fbd7a1fe906351b8ffbe75fd1efa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 Mar 2023 11:42:53 -0500 Subject: [PATCH 0387/1058] Await block till done inside patched config entry in tests (#89515) --- tests/components/advantage_air/test_config_flow.py | 2 +- tests/components/bond/test_config_flow.py | 2 +- tests/components/epson/test_config_flow.py | 2 +- tests/components/faa_delays/test_config_flow.py | 2 +- tests/components/kmtronic/test_config_flow.py | 2 +- tests/components/kostal_plenticore/test_config_flow.py | 2 +- tests/components/kulersky/test_config_flow.py | 4 ++-- tests/components/lutron_caseta/test_config_flow.py | 2 +- tests/components/nanoleaf/test_config_flow.py | 5 +++-- tests/components/nws/test_config_flow.py | 3 ++- tests/components/onewire/test_config_flow.py | 5 ++--- tests/components/syncthru/test_config_flow.py | 2 +- 12 files changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/components/advantage_air/test_config_flow.py b/tests/components/advantage_air/test_config_flow.py index 4783e9cb6353..fc74df5538b0 100644 --- a/tests/components/advantage_air/test_config_flow.py +++ b/tests/components/advantage_air/test_config_flow.py @@ -33,12 +33,12 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> result1["flow_id"], USER_INPUT, ) + await hass.async_block_till_done() assert len(aioclient_mock.mock_calls) == 1 assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "testname" assert result2["data"] == USER_INPUT - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 # Test Duplicate Config Flow diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index a060def1cb65..fab579a81a3c 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -187,11 +187,11 @@ async def test_user_form_one_entry_per_device_allowed(hass: HomeAssistant) -> No result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) + await hass.async_block_till_done() assert result2["type"] == "abort" assert result2["reason"] == "already_configured" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/epson/test_config_flow.py b/tests/components/epson/test_config_flow.py index fadfb2085b1e..be0267a4af88 100644 --- a/tests/components/epson/test_config_flow.py +++ b/tests/components/epson/test_config_flow.py @@ -33,11 +33,11 @@ async def test_form(hass: HomeAssistant) -> None: result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_NAME: "test-epson"}, ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "test-epson" assert result2["data"] == {CONF_HOST: "1.1.1.1"} - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/faa_delays/test_config_flow.py b/tests/components/faa_delays/test_config_flow.py index ce7f86e60605..9eb166d5f697 100644 --- a/tests/components/faa_delays/test_config_flow.py +++ b/tests/components/faa_delays/test_config_flow.py @@ -37,13 +37,13 @@ async def test_form(hass: HomeAssistant) -> None: "id": "test", }, ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "Test airport" assert result2["data"] == { "id": "test", } - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/kmtronic/test_config_flow.py b/tests/components/kmtronic/test_config_flow.py index 76d11b8451e6..ba8f2f5b87ee 100644 --- a/tests/components/kmtronic/test_config_flow.py +++ b/tests/components/kmtronic/test_config_flow.py @@ -37,6 +37,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: "password": "test-password", }, ) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == "1.1.1.1" @@ -45,7 +46,6 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: "username": "test-username", "password": "test-password", } - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index 5d67ca3ae662..3c64a48c218d 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -47,6 +47,7 @@ async def test_formx(hass: HomeAssistant) -> None: "password": "test-password", }, ) + await hass.async_block_till_done() mock_api_class.assert_called_once_with(ANY, "1.1.1.1") mock_api.__aenter__.assert_called_once() @@ -60,7 +61,6 @@ async def test_formx(hass: HomeAssistant) -> None: "host": "1.1.1.1", "password": "test-password", } - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/kulersky/test_config_flow.py b/tests/components/kulersky/test_config_flow.py index 3a26f16f3a0e..a09fc78797b5 100644 --- a/tests/components/kulersky/test_config_flow.py +++ b/tests/components/kulersky/test_config_flow.py @@ -60,10 +60,10 @@ async def test_flow_no_devices_found(hass: HomeAssistant) -> None: result["flow_id"], {}, ) + await hass.async_block_till_done() assert result2["type"] == "abort" assert result2["reason"] == "no_devices_found" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 @@ -87,8 +87,8 @@ async def test_flow_exceptions_caught(hass: HomeAssistant) -> None: result["flow_id"], {}, ) + await hass.async_block_till_done() assert result2["type"] == "abort" assert result2["reason"] == "no_devices_found" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index 72a0d4b71ac2..cc71eb5910f6 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -67,13 +67,13 @@ async def test_bridge_import_flow(hass: HomeAssistant) -> None: context={"source": config_entries.SOURCE_IMPORT}, data=entry_mock_data, ) + await hass.async_block_till_done() assert result["type"] == "create_entry" assert result["title"] == CasetaConfigFlow.ENTRY_DEFAULT_TITLE assert result["data"] == entry_mock_data assert result["result"].unique_id == "000004d2" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 200e2c215479..9a7f4a2bc508 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -381,6 +381,8 @@ async def test_import_discovery_integration( type=type_in_discovery, ), ) + await hass.async_block_till_done() + assert result["type"] == "create_entry" assert result["title"] == TEST_NAME assert result["data"] == { @@ -395,7 +397,6 @@ async def test_import_discovery_integration( mock_save_json.assert_called_once() mock_remove.assert_not_called() - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 @@ -431,6 +432,7 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: assert result["step_id"] == "link" result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() assert result2["type"] == "create_entry" assert result2["title"] == TEST_NAME @@ -439,5 +441,4 @@ async def test_ssdp_discovery(hass: HomeAssistant) -> None: CONF_TOKEN: TEST_TOKEN, } - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nws/test_config_flow.py b/tests/components/nws/test_config_flow.py index 28e9db253639..9c02139d67c9 100644 --- a/tests/components/nws/test_config_flow.py +++ b/tests/components/nws/test_config_flow.py @@ -108,7 +108,8 @@ async def test_form_already_configured( result["flow_id"], {"api_key": "test"}, ) + await hass.async_block_till_done() + assert result2["type"] == "abort" assert result2["reason"] == "already_configured" - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index 63e53627e0e2..d69f9a93200e 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -76,7 +76,8 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No CONF_HOST: "1.2.3.4", CONF_PORT: 1234, } - await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 @@ -102,8 +103,6 @@ async def test_user_duplicate( ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.usefixtures("filled_device_registry") diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index e1e7b01ac098..ae6172af6d89 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -119,10 +119,10 @@ async def test_success( context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT, ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] - await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 From 288a4203ab10a8602a1073e926d6ccad9af28911 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 11 Mar 2023 06:23:49 +0100 Subject: [PATCH 0388/1058] Make client tracker use common UniFi entity class (#84942) * Make client tracker use common UniFi entity class * Fix tests * Fix mypy * Remove legacy data * Fix comment: skip else use return * Minor change * Remove missed stuff from previous rebase * Import async_device_available_fn from entities.py rather than specifying it in device_tracker * Avoid using asserts * Keep explicit parenthesis for readability * Allow loading entities on option changes --- .../components/unifi/device_tracker.py | 380 ++++++------------ tests/components/unifi/test_device_tracker.py | 26 +- 2 files changed, 144 insertions(+), 262 deletions(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index c845b6d5d389..a5b153d7f361 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -2,20 +2,21 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import timedelta import logging -from typing import Generic, TypeVar +from typing import Any, Generic import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.devices import Devices -from aiounifi.models.api import SOURCE_DATA, SOURCE_EVENT +from aiounifi.models.client import Client from aiounifi.models.device import Device -from aiounifi.models.event import EventKey +from aiounifi.models.event import Event, EventKey -from homeassistant.components.device_tracker import DOMAIN, ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -24,8 +25,13 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController -from .entity import UnifiEntity, UnifiEntityDescription -from .unifi_client import UniFiClientBase +from .entity import ( + DataT, + HandlerT, + UnifiEntity, + UnifiEntityDescription, + async_device_available_fn, +) LOGGER = logging.getLogger(__name__) @@ -58,6 +64,7 @@ CLIENT_STATIC_ATTRIBUTES = [ CLIENT_CONNECTED_ALL_ATTRIBUTES = CLIENT_CONNECTED_ATTRIBUTES + CLIENT_STATIC_ATTRIBUTES WIRED_CONNECTION = (EventKey.WIRED_CLIENT_CONNECTED,) +WIRED_DISCONNECTION = (EventKey.WIRED_CLIENT_DISCONNECTED,) WIRELESS_CONNECTION = ( EventKey.WIRELESS_CLIENT_CONNECTED, EventKey.WIRELESS_CLIENT_ROAM, @@ -66,17 +73,57 @@ WIRELESS_CONNECTION = ( EventKey.WIRELESS_GUEST_ROAM, EventKey.WIRELESS_GUEST_ROAM_RADIO, ) - - -_DataT = TypeVar("_DataT", bound=Device) -_HandlerT = TypeVar("_HandlerT", bound=Devices) +WIRELESS_DISCONNECTION = ( + EventKey.WIRELESS_CLIENT_DISCONNECTED, + EventKey.WIRELESS_GUEST_DISCONNECTED, +) @callback -def async_device_available_fn(controller: UniFiController, obj_id: str) -> bool: +def async_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client is allowed.""" + if not controller.option_track_clients: + return False + + client = controller.api.clients[obj_id] + if client.mac not in controller.wireless_clients: + if not controller.option_track_wired_clients: + return False + + elif ( + client.essid + and controller.option_ssid_filter + and client.essid not in controller.option_ssid_filter + ): + return False + + return True + + +@callback +def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bool: """Check if device object is disabled.""" - device = controller.api.devices[obj_id] - return controller.available and not device.disabled + client = controller.api.clients[obj_id] + + if client.is_wired != (obj_id not in controller.wireless_clients): + if not controller.option_ignore_wired_bug: + return False # Wired bug in action + + if ( + not client.is_wired + and client.essid + and controller.option_ssid_filter + and client.essid not in controller.option_ssid_filter + ): + return False + + if ( + dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0) + > controller.option_detection_time + ): + return False + + return True @callback @@ -89,7 +136,7 @@ def async_device_heartbeat_timedelta_fn( @dataclass -class UnifiEntityTrackerDescriptionMixin(Generic[_HandlerT, _DataT]): +class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): """Device tracker local functions.""" heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] @@ -100,13 +147,36 @@ class UnifiEntityTrackerDescriptionMixin(Generic[_HandlerT, _DataT]): @dataclass class UnifiTrackerEntityDescription( - UnifiEntityDescription[_HandlerT, _DataT], - UnifiEntityTrackerDescriptionMixin[_HandlerT, _DataT], + UnifiEntityDescription[HandlerT, DataT], + UnifiEntityTrackerDescriptionMixin[HandlerT, DataT], ): """Class describing UniFi device tracker entity.""" ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( + UnifiTrackerEntityDescription[Clients, Client]( + key="Client device scanner", + has_entity_name=True, + allowed_fn=async_client_allowed_fn, + api_handler_fn=lambda api: api.clients, + available_fn=lambda controller, obj_id: controller.available, + device_info_fn=lambda api, obj_id: None, + event_is_on=(WIRED_CONNECTION + WIRELESS_CONNECTION), + event_to_subscribe=( + WIRED_CONNECTION + + WIRED_DISCONNECTION + + WIRELESS_CONNECTION + + WIRELESS_DISCONNECTION + ), + heartbeat_timedelta_fn=lambda controller, _: controller.option_detection_time, + is_connected_fn=async_client_is_connected_fn, + name_fn=lambda client: client.name or client.hostname, + object_fn=lambda api, obj_id: api.clients[obj_id], + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"{obj_id}-{controller.site}", + ip_address_fn=lambda api, obj_id: api.clients[obj_id].ip, + hostname_fn=lambda api, obj_id: None, + ), UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", has_entity_name=True, @@ -140,239 +210,13 @@ async def async_setup_entry( UnifiScannerEntity, ENTITY_DESCRIPTIONS, async_add_entities ) - controller.entities[DOMAIN] = {CLIENT_TRACKER: set(), DEVICE_TRACKER: set()} - @callback - def items_added( - clients: set = controller.api.clients, devices: set = controller.api.devices - ) -> None: - """Update the values of the controller.""" - if controller.option_track_clients: - add_client_entities(controller, async_add_entities, clients) - - for signal in (controller.signal_update, controller.signal_options_update): - config_entry.async_on_unload( - async_dispatcher_connect(hass, signal, items_added) - ) - - items_added() - - -@callback -def add_client_entities(controller, async_add_entities, clients): - """Add new client tracker entities from the controller.""" - trackers = [] - - for mac in clients: - if mac in controller.entities[DOMAIN][UniFiClientTracker.TYPE] or not ( - client := controller.api.clients.get(mac) - ): - continue - - if mac not in controller.wireless_clients: - if not controller.option_track_wired_clients: - continue - elif ( - client.essid - and controller.option_ssid_filter - and client.essid not in controller.option_ssid_filter - ): - continue - - trackers.append(UniFiClientTracker(client, controller)) - - async_add_entities(trackers) - - -class UniFiClientTracker(UniFiClientBase, ScannerEntity): - """Representation of a network client.""" - - DOMAIN = DOMAIN - TYPE = CLIENT_TRACKER - - def __init__(self, client, controller): - """Set up tracked client.""" - super().__init__(client, controller) - - self._controller_connection_state_changed = False - - self._only_listen_to_data_source = False - - last_seen = client.last_seen or 0 - self.schedule_update = self._is_connected = ( - self.is_wired == client.is_wired - and dt_util.utcnow() - dt_util.utc_from_timestamp(float(last_seen)) - < controller.option_detection_time - ) - - @callback - def _async_log_debug_data(self, method: str) -> None: - """Print debug data about entity.""" - if not LOGGER.isEnabledFor(logging.DEBUG): - return - last_seen = self.client.last_seen or 0 - LOGGER.debug( - "%s [%s, %s] [%s %s] [%s] %s (%s)", - method, - self.entity_id, - self.client.mac, - self.schedule_update, - self._is_connected, - dt_util.utc_from_timestamp(float(last_seen)), - dt_util.utcnow() - dt_util.utc_from_timestamp(float(last_seen)), - last_seen, - ) - - async def async_added_to_hass(self) -> None: - """Watch object when added.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", - self._make_disconnected, - ) - ) - await super().async_added_to_hass() - self._async_log_debug_data("added_to_hass") - - async def async_will_remove_from_hass(self) -> None: - """Disconnect object when removed.""" - self.controller.async_heartbeat(self.unique_id) - await super().async_will_remove_from_hass() - - @callback - def async_signal_reachable_callback(self) -> None: - """Call when controller connection state change.""" - self._controller_connection_state_changed = True - super().async_signal_reachable_callback() - - @callback - def async_update_callback(self) -> None: - """Update the clients state.""" - - if self._controller_connection_state_changed: - self._controller_connection_state_changed = False - - if self.controller.available: - self.schedule_update = True - - else: - self.controller.async_heartbeat(self.unique_id) - super().async_update_callback() - - elif ( - self.client.last_updated == SOURCE_DATA - and self.is_wired == self.client.is_wired - ): - self._is_connected = True - self.schedule_update = True - self._only_listen_to_data_source = True - - elif ( - self.client.last_updated == SOURCE_EVENT - and not self._only_listen_to_data_source - ): - if (self.is_wired and self.client.event.key in WIRED_CONNECTION) or ( - not self.is_wired and self.client.event.key in WIRELESS_CONNECTION - ): - self._is_connected = True - self.schedule_update = False - self.controller.async_heartbeat(self.unique_id) - super().async_update_callback() - - else: - self.schedule_update = True - - self._async_log_debug_data("update_callback") - - if self.schedule_update: - self.schedule_update = False - self.controller.async_heartbeat( - self.unique_id, dt_util.utcnow() + self.controller.option_detection_time - ) - - super().async_update_callback() - - @callback - def _make_disconnected(self, *_): - """No heart beat by device.""" - self._is_connected = False - self.async_write_ha_state() - self._async_log_debug_data("make_disconnected") - - @property - def is_connected(self): - """Return true if the client is connected to the network.""" - if ( - not self.is_wired - and self.client.essid - and self.controller.option_ssid_filter - and self.client.essid not in self.controller.option_ssid_filter - ): - return False - - return self._is_connected - - @property - def source_type(self) -> SourceType: - """Return the source type of the client.""" - return SourceType.ROUTER - - @property - def unique_id(self) -> str: - """Return a unique identifier for this client.""" - return f"{self.client.mac}-{self.controller.site}" - - @property - def extra_state_attributes(self): - """Return the client state attributes.""" - raw = self.client.raw - - attributes_to_check = CLIENT_STATIC_ATTRIBUTES - if self.is_connected: - attributes_to_check = CLIENT_CONNECTED_ALL_ATTRIBUTES - - attributes = {k: raw[k] for k in attributes_to_check if k in raw} - attributes["is_wired"] = self.is_wired - - return attributes - - @property - def ip_address(self) -> str: - """Return the primary ip address of the device.""" - return self.client.raw.get("ip") - - @property - def mac_address(self) -> str: - """Return the mac address of the device.""" - return self.client.raw.get("mac") - - @property - def hostname(self) -> str: - """Return hostname of the device.""" - return self.client.raw.get("hostname") - - async def options_updated(self) -> None: - """Config entry options are updated, remove entity if option is disabled.""" - if not self.controller.option_track_clients: - await self.remove_item({self.client.mac}) - - elif self.is_wired: - if not self.controller.option_track_wired_clients: - await self.remove_item({self.client.mac}) - - elif ( - self.controller.option_ssid_filter - and self.client.essid not in self.controller.option_ssid_filter - ): - await self.remove_item({self.client.mac}) - - -class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): +class UnifiScannerEntity(UnifiEntity[HandlerT, DataT], ScannerEntity): """Representation of a UniFi scanner.""" entity_description: UnifiTrackerEntityDescription + _event_is_on: tuple[EventKey, ...] _ignore_events: bool _is_connected: bool @@ -383,8 +227,15 @@ class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): Initiate is_connected. """ description = self.entity_description + self._event_is_on = description.event_is_on or () self._ignore_events = False self._is_connected = description.is_connected_fn(self.controller, self._obj_id) + if self.is_connected: + self.controller.async_heartbeat( + self.unique_id, + dt_util.utcnow() + + description.heartbeat_timedelta_fn(self.controller, self._obj_id), + ) @property def is_connected(self) -> bool: @@ -452,13 +303,33 @@ class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): + description.heartbeat_timedelta_fn(self.controller, self._obj_id), ) + @callback + def async_event_callback(self, event: Event) -> None: + """Event subscription callback.""" + if event.mac != self._obj_id or self._ignore_events: + return + + if event.key in self._event_is_on: + self.controller.async_heartbeat(self.unique_id) + self._is_connected = True + self.async_write_ha_state() + return + + self.controller.async_heartbeat( + self.unique_id, + dt_util.utcnow() + + self.entity_description.heartbeat_timedelta_fn( + self.controller, self._obj_id + ), + ) + async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, - f"{self.controller.signal_heartbeat_missed}_{self._obj_id}", + f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", self._make_disconnected, ) ) @@ -467,3 +338,20 @@ class UnifiScannerEntity(UnifiEntity[_HandlerT, _DataT], ScannerEntity): """Disconnect object when removed.""" await super().async_will_remove_from_hass() self.controller.async_heartbeat(self.unique_id) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the client state attributes.""" + if self.entity_description.key != "Client device scanner": + return None + + client = self.entity_description.object_fn(self.controller.api, self._obj_id) + raw = client.raw + + attributes_to_check = CLIENT_STATIC_ATTRIBUTES + if self.is_connected: + attributes_to_check = CLIENT_CONNECTED_ALL_ATTRIBUTES + + attributes = {k: raw[k] for k in attributes_to_check if k in raw} + + return attributes diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 5dcf1fc69328..1e68b497111f 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -156,7 +156,7 @@ async def test_tracked_clients( # State change signalling works - client_1["last_seen"] += 1 + client_1["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) mock_unifi_websocket(message=MessageKey.CLIENT, data=client_1) await hass.async_block_till_done() @@ -244,6 +244,7 @@ async def test_tracked_wireless_clients_event_source( # New data + client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() assert hass.states.get("device_tracker.client").state == STATE_HOME @@ -703,6 +704,11 @@ async def test_option_ssid_filter( mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() + new_time = dt_util.utcnow() + controller.option_detection_time + with patch("homeassistant.util.dt.utcnow", return_value=new_time): + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + # SSID filter marks client as away assert hass.states.get("device_tracker.client").state == STATE_NOT_HOME @@ -726,7 +732,7 @@ async def test_option_ssid_filter( # Time pass to mark client as away - new_time = dt_util.utcnow() + controller.option_detection_time + new_time += controller.option_detection_time with patch("homeassistant.util.dt.utcnow", return_value=new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -745,9 +751,7 @@ async def test_option_ssid_filter( mock_unifi_websocket(message=MessageKey.CLIENT, data=client_on_ssid2) await hass.async_block_till_done() - new_time = ( - dt_util.utcnow() + controller.option_detection_time + timedelta(seconds=1) - ) + new_time += controller.option_detection_time with patch("homeassistant.util.dt.utcnow", return_value=new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -784,10 +788,9 @@ async def test_wireless_client_go_wired_issue( # Client is wireless client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False # Trigger wired bug - client["last_seen"] += 1 + client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) client["is_wired"] = True mock_unifi_websocket(message=MessageKey.CLIENT, data=client) await hass.async_block_till_done() @@ -795,7 +798,6 @@ async def test_wireless_client_go_wired_issue( # Wired bug fix keeps client marked as wireless client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False # Pass time new_time = dt_util.utcnow() + controller.option_detection_time @@ -806,7 +808,6 @@ async def test_wireless_client_go_wired_issue( # Marked as home according to the timer client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_NOT_HOME - assert client_state.attributes["is_wired"] is False # Try to mark client as connected client["last_seen"] += 1 @@ -816,7 +817,6 @@ async def test_wireless_client_go_wired_issue( # Make sure it don't go online again until wired bug disappears client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_NOT_HOME - assert client_state.attributes["is_wired"] is False # Make client wireless client["last_seen"] += 1 @@ -827,7 +827,6 @@ async def test_wireless_client_go_wired_issue( # Client is no longer affected by wired bug and can be marked online client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False async def test_option_ignore_wired_bug( @@ -859,7 +858,6 @@ async def test_option_ignore_wired_bug( # Client is wireless client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False # Trigger wired bug client["is_wired"] = True @@ -869,7 +867,6 @@ async def test_option_ignore_wired_bug( # Wired bug in effect client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is True # pass time new_time = dt_util.utcnow() + controller.option_detection_time @@ -880,7 +877,6 @@ async def test_option_ignore_wired_bug( # Timer marks client as away client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_NOT_HOME - assert client_state.attributes["is_wired"] is True # Mark client as connected again client["last_seen"] += 1 @@ -890,7 +886,6 @@ async def test_option_ignore_wired_bug( # Ignoring wired bug allows client to go home again even while affected client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is True # Make client wireless client["last_seen"] += 1 @@ -901,7 +896,6 @@ async def test_option_ignore_wired_bug( # Client is wireless and still connected client_state = hass.states.get("device_tracker.client") assert client_state.state == STATE_HOME - assert client_state.attributes["is_wired"] is False async def test_restoring_client( From fccdd7b102376622ae19ac1b45215b9184c19e73 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 11 Mar 2023 09:15:05 +0100 Subject: [PATCH 0389/1058] Fix bug in fibaro cover (#89502) --- homeassistant/components/fibaro/cover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index e19c5c32e8a3..c73c45d254c6 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -94,9 +94,9 @@ class FibaroCover(FibaroDevice, CoverEntity): """Return if the cover is closed.""" if self._is_open_close_only(): state = self.fibaro_device.state - if not state.has_value or state.str_value.lower() == "unknown": + if not state.has_value or state.str_value().lower() == "unknown": return None - return state.str_value.lower() == "closed" + return state.str_value().lower() == "closed" if self.current_cover_position is None: return None From 01e12214437065e10f7e35c550d3f9c7af0589f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 01:45:27 -1000 Subject: [PATCH 0390/1058] Refactor logbook data to use a dataclass (#89534) --- homeassistant/components/logbook/__init__.py | 20 ++++++++++--------- homeassistant/components/logbook/const.py | 3 --- homeassistant/components/logbook/helpers.py | 7 +++---- homeassistant/components/logbook/models.py | 14 +++++++++++++ homeassistant/components/logbook/processor.py | 11 ++++------ .../components/logbook/websocket_api.py | 7 ++++--- tests/components/logbook/common.py | 4 +++- 7 files changed, 39 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index fb1b9d78b89d..ee2ae3da4d95 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ATTR_NAME, EVENT_LOGBOOK_ENTRY, ) -from homeassistant.core import Context, Event, HomeAssistant, ServiceCall, callback +from homeassistant.core import Context, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, @@ -35,7 +35,6 @@ from . import rest_api, websocket_api from .const import ( # noqa: F401 ATTR_MESSAGE, DOMAIN, - LOGBOOK_ENTITIES_FILTER, LOGBOOK_ENTRY_CONTEXT_ID, LOGBOOK_ENTRY_DOMAIN, LOGBOOK_ENTRY_ENTITY_ID, @@ -43,9 +42,8 @@ from .const import ( # noqa: F401 LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME, LOGBOOK_ENTRY_SOURCE, - LOGBOOK_FILTERS, ) -from .models import LazyEventPartialState # noqa: F401 +from .models import LazyEventPartialState, LogbookConfig CONFIG_SCHEMA = vol.Schema( {DOMAIN: INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA}, extra=vol.ALLOW_EXTRA @@ -97,7 +95,6 @@ def async_log_entry( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Logbook setup.""" - hass.data[DOMAIN] = {} @callback def log_message(service: ServiceCall) -> None: @@ -134,8 +131,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: filters = None entities_filter = None - hass.data[LOGBOOK_FILTERS] = filters - hass.data[LOGBOOK_ENTITIES_FILTER] = entities_filter + + external_events: dict[ + str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] + ] = {} + hass.data[DOMAIN] = LogbookConfig(external_events, filters, entities_filter) websocket_api.async_setup(hass) rest_api.async_setup(hass, config, filters, entities_filter) hass.services.async_register(DOMAIN, "log", log_message, schema=LOG_MESSAGE_SCHEMA) @@ -149,14 +149,16 @@ async def _process_logbook_platform( hass: HomeAssistant, domain: str, platform: Any ) -> None: """Process a logbook platform.""" + logbook_config: LogbookConfig = hass.data[DOMAIN] + external_events = logbook_config.external_events @callback def _async_describe_event( domain: str, event_name: str, - describe_callback: Callable[[Event], dict[str, Any]], + describe_callback: Callable[[LazyEventPartialState], dict[str, Any]], ) -> None: """Teach logbook how to describe a new event.""" - hass.data[DOMAIN][event_name] = (domain, describe_callback) + external_events[event_name] = (domain, describe_callback) platform.async_describe_events(hass, _async_describe_event) diff --git a/homeassistant/components/logbook/const.py b/homeassistant/components/logbook/const.py index e1abd9876595..2d9911117f96 100644 --- a/homeassistant/components/logbook/const.py +++ b/homeassistant/components/logbook/const.py @@ -44,6 +44,3 @@ AUTOMATION_EVENTS = {EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED} # Events that are built-in to the logbook or core BUILT_IN_EVENTS = {EVENT_LOGBOOK_ENTRY, EVENT_CALL_SERVICE} - -LOGBOOK_FILTERS = "logbook_filters" -LOGBOOK_ENTITIES_FILTER = "entities_filter" diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 221612e1e978..c8f55331de13 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_state_change_event from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN -from .models import LazyEventPartialState +from .models import LogbookConfig def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[str]: @@ -63,9 +63,8 @@ def async_determine_event_types( hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None ) -> tuple[str, ...]: """Reduce the event types based on the entity ids and device ids.""" - external_events: dict[ - str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] - ] = hass.data.get(DOMAIN, {}) + logbook_config: LogbookConfig = hass.data[DOMAIN] + external_events = logbook_config.external_events if not entity_ids and not device_ids: return (*BUILT_IN_EVENTS, *external_events) diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index a5ce9eddcec2..ab073f296f7f 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -1,11 +1,13 @@ """Event parser and human readable log generator.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast from sqlalchemy.engine.row import Row +from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( bytes_to_ulid_or_none, bytes_to_uuid_hex_or_none, @@ -14,11 +16,23 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED from homeassistant.core import Context, Event, State, callback +from homeassistant.helpers.entityfilter import EntityFilter import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes +@dataclass +class LogbookConfig: + """Configuration for the logbook integration.""" + + external_events: dict[ + str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] + ] + sqlalchemy_filter: Filters | None = None + entity_filter: EntityFilter | None = None + + class LazyEventPartialState: """A lazy version of core Event with limited State joined in.""" diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 39d8e920aa4a..f816064ba69e 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -52,10 +52,9 @@ from .const import ( LOGBOOK_ENTRY_SOURCE, LOGBOOK_ENTRY_STATE, LOGBOOK_ENTRY_WHEN, - LOGBOOK_FILTERS, ) from .helpers import is_sensor_continuous -from .models import EventAsRow, LazyEventPartialState, async_event_to_row +from .models import EventAsRow, LazyEventPartialState, LogbookConfig, async_event_to_row from .queries import statement_for_request from .queries.common import PSEUDO_EVENT_STATE_CHANGED @@ -97,16 +96,14 @@ class EventProcessor: self.entity_ids = entity_ids self.device_ids = device_ids self.context_id = context_id - self.filters: Filters | None = hass.data[LOGBOOK_FILTERS] + logbook_config: LogbookConfig = hass.data[DOMAIN] + self.filters: Filters | None = logbook_config.sqlalchemy_filter format_time = ( _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat ) - external_events: dict[ - str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] - ] = hass.data.get(DOMAIN, {}) self.logbook_run = LogbookRun( context_lookup=ContextLookup(hass), - external_events=external_events, + external_events=logbook_config.external_events, event_cache=EventCache({}), entity_name_cache=EntityNameCache(self.hass), include_entity_name=include_entity_name, diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index dac0da83c360..6d24285ba11b 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -20,13 +20,13 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util -from .const import LOGBOOK_ENTITIES_FILTER +from .const import DOMAIN from .helpers import ( async_determine_event_types, async_filter_entities, async_subscribe_events, ) -from .models import async_event_to_row +from .models import LogbookConfig, async_event_to_row from .processor import EventProcessor MAX_PENDING_LOGBOOK_EVENTS = 2048 @@ -361,7 +361,8 @@ async def ws_event_stream( entities_filter: EntityFilter | None = None if not event_processor.limited_select: - entities_filter = hass.data[LOGBOOK_ENTITIES_FILTER] + logbook_config: LogbookConfig = hass.data[DOMAIN] + entities_filter = logbook_config.entity_filter async_subscribe_events( hass, diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index d08366e2f1bb..b8d00681112d 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -6,6 +6,7 @@ from typing import Any from homeassistant.components import logbook from homeassistant.components.logbook import processor +from homeassistant.components.logbook.models import LogbookConfig from homeassistant.components.recorder.models import ( process_timestamp_to_utc_isoformat, ulid_to_bytes_or_none, @@ -64,7 +65,8 @@ def mock_humanify(hass_, rows): ent_reg = er.async_get(hass_) event_cache = processor.EventCache({}) context_lookup = processor.ContextLookup(hass_) - external_events = hass_.data.get(logbook.DOMAIN, {}) + logbook_config = hass_.data.get(logbook.DOMAIN, LogbookConfig({}, None, None)) + external_events = logbook_config.external_events logbook_run = processor.LogbookRun( context_lookup, external_events, From 52cea16f74c54e94324e84cd17aeadc49f6fcdab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 01:46:12 -1000 Subject: [PATCH 0391/1058] Remove unused code in RecorderRuns.entity_ids (#89526) --- .../components/recorder/db_schema.py | 24 ------ .../recorder/table_managers/__init__.py | 1 + tests/components/recorder/test_models.py | 76 ------------------- 3 files changed, 1 insertion(+), 100 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/__init__.py diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index f794c64714e0..01b15dd37818 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -25,14 +25,11 @@ from sqlalchemy import ( SmallInteger, String, Text, - distinct, type_coerce, ) from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column, relationship -from sqlalchemy.orm.query import RowReturningQuery -from sqlalchemy.orm.session import Session from typing_extensions import Self from homeassistant.const import ( @@ -699,27 +696,6 @@ class RecorderRuns(Base): f" created='{self.created.isoformat(sep=' ', timespec='seconds')}')>" ) - def entity_ids(self, point_in_time: datetime | None = None) -> list[str]: - """Return the entity ids that existed in this run. - - Specify point_in_time if you want to know which existed at that point - in time inside the run. - """ - session = Session.object_session(self) - - assert session is not None, "RecorderRuns need to be persisted" - - query: RowReturningQuery[tuple[str]] = session.query(distinct(States.entity_id)) - - query = query.filter(States.last_updated >= self.start) - - if point_in_time is not None: - query = query.filter(States.last_updated < point_in_time) - elif self.end is not None: - query = query.filter(States.last_updated < self.end) - - return [row[0] for row in query] - def to_native(self, validate_entity_id: bool = True) -> Self: """Return self, native format is this model.""" return self diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py new file mode 100644 index 000000000000..c011520204b1 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -0,0 +1 @@ +"""Managers for each table.""" diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index a1ab4508042d..6f4de420b7b7 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -4,15 +4,11 @@ from unittest.mock import PropertyMock from freezegun import freeze_time import pytest -from sqlalchemy import create_engine -from sqlalchemy.orm import scoped_session, sessionmaker from homeassistant.components.recorder.const import SupportedDialect from homeassistant.components.recorder.db_schema import ( - Base, EventData, Events, - RecorderRuns, StateAttributes, States, ) @@ -151,78 +147,6 @@ def test_from_event_to_delete_state() -> None: assert db_state.last_updated_ts == event.time_fired.timestamp() -def test_entity_ids(recorder_db_url: str) -> None: - """Test if entity ids helper method works.""" - if recorder_db_url.startswith("mysql://"): - # Dropping the database after this test will fail on MySQL - # because it will create an InnoDB deadlock. - return - engine = create_engine(recorder_db_url) - Base.metadata.create_all(engine) - session_factory = sessionmaker(bind=engine) - - session = scoped_session(session_factory) - session.query(Events).delete() - session.query(States).delete() - session.query(RecorderRuns).delete() - - run = RecorderRuns( - start=datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC), - end=datetime(2016, 7, 9, 23, 0, 0, tzinfo=dt.UTC), - closed_incorrect=False, - created=datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC), - ) - - session.add(run) - session.commit() - - before_run = datetime(2016, 7, 9, 8, 0, 0, tzinfo=dt.UTC) - in_run = datetime(2016, 7, 9, 13, 0, 0, tzinfo=dt.UTC) - in_run2 = datetime(2016, 7, 9, 15, 0, 0, tzinfo=dt.UTC) - in_run3 = datetime(2016, 7, 9, 18, 0, 0, tzinfo=dt.UTC) - after_run = datetime(2016, 7, 9, 23, 30, 0, tzinfo=dt.UTC) - - assert run.to_native() == run - assert run.entity_ids() == [] - - session.add( - States( - entity_id="sensor.temperature", - state="20", - last_changed=before_run, - last_updated=before_run, - ) - ) - session.add( - States( - entity_id="sensor.sound", - state="10", - last_changed=after_run, - last_updated=after_run, - ) - ) - - session.add( - States( - entity_id="sensor.humidity", - state="76", - last_changed=in_run, - last_updated=in_run, - ) - ) - session.add( - States( - entity_id="sensor.lux", - state="5", - last_changed=in_run3, - last_updated=in_run3, - ) - ) - - assert sorted(run.entity_ids()) == ["sensor.humidity", "sensor.lux"] - assert run.entity_ids(in_run2) == ["sensor.humidity"] - - def test_states_from_native_invalid_entity_id() -> None: """Test loading a state from an invalid entity ID.""" state = States() From 7487a004fd8a3c0ce693009f8ab5f9eef7ee3e92 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 11 Mar 2023 20:13:27 +0100 Subject: [PATCH 0392/1058] Bump pydeconz to v110 (#89527) * Bump pydeconz to v109 * Bump pydeconz to v110 for additional color modes --- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index b0ad90857b54..5569f9d5e8a9 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==108"], + "requirements": ["pydeconz==110"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index a83826662770..7cf150b8119a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1573,7 +1573,7 @@ pydaikin==2.9.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==108 +pydeconz==110 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1dfb0466be64..ac6045d58b0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1137,7 +1137,7 @@ pycoolmasternet-async==0.1.5 pydaikin==2.9.0 # homeassistant.components.deconz -pydeconz==108 +pydeconz==110 # homeassistant.components.dexcom pydexcom==0.2.3 From 8564768d9ed295a695fde5aa1a36a54a22322a20 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 11 Mar 2023 20:14:39 +0100 Subject: [PATCH 0393/1058] UniFi library controls add/update signalling (#89525) * Library controls add/update signalling * Remove add/remove signalling * Remove unifi_entity_base and unifi_client to make mypy pass --- homeassistant/components/unifi/controller.py | 62 ++---------- .../components/unifi/unifi_client.py | 58 ----------- .../components/unifi/unifi_entity_base.py | 97 ------------------- tests/components/unifi/test_controller.py | 2 - tests/components/unifi/test_sensor.py | 38 -------- tests/components/unifi/test_switch.py | 6 -- 6 files changed, 8 insertions(+), 255 deletions(-) delete mode 100644 homeassistant/components/unifi/unifi_client.py delete mode 100644 homeassistant/components/unifi/unifi_entity_base.py diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 69c3cc780597..8a047606c67c 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,7 +10,7 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.interfaces.messages import DATA_CLIENT_REMOVED, DATA_EVENT +from aiounifi.interfaces.messages import DATA_EVENT from aiounifi.models.event import EventKey from aiounifi.websocket import WebsocketSignal, WebsocketState import async_timeout @@ -73,17 +73,6 @@ from .errors import AuthenticationRequired, CannotConnect RETRY_TIMER = 15 CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) -CLIENT_CONNECTED = ( - EventKey.WIRED_CLIENT_CONNECTED, - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, -) -DEVICE_CONNECTED = ( - EventKey.ACCESS_POINT_CONNECTED, - EventKey.GATEWAY_CONNECTED, - EventKey.SWITCH_CONNECTED, -) - class UniFiController: """Manages a single UniFi Network instance.""" @@ -258,55 +247,20 @@ class UniFiController: else: LOGGER.info("Connected to UniFi Network") - elif signal == WebsocketSignal.DATA and data: - if DATA_EVENT in data: - clients_connected = set() - devices_connected = set() - wireless_clients_connected = False - - for event in data[DATA_EVENT]: - if event.key in CLIENT_CONNECTED: - clients_connected.add(event.mac) - - if not wireless_clients_connected and event.key in ( - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, - ): - wireless_clients_connected = True - - elif event.key in DEVICE_CONNECTED: - devices_connected.add(event.mac) - - if wireless_clients_connected: + elif signal == WebsocketSignal.DATA and DATA_EVENT in data: + for event in data[DATA_EVENT]: + if event.key in ( + EventKey.WIRELESS_CLIENT_CONNECTED, + EventKey.WIRELESS_GUEST_CONNECTED, + ): self.update_wireless_clients() - if clients_connected or devices_connected: - async_dispatcher_send( - self.hass, - self.signal_update, - clients_connected, - devices_connected, - ) - - elif DATA_CLIENT_REMOVED in data: - async_dispatcher_send( - self.hass, self.signal_remove, data[DATA_CLIENT_REMOVED] - ) + break @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" return f"unifi-reachable-{self.config_entry.entry_id}" - @property - def signal_update(self) -> str: - """Event specific per UniFi entry to signal new data.""" - return f"unifi-update-{self.config_entry.entry_id}" - - @property - def signal_remove(self) -> str: - """Event specific per UniFi entry to signal removal of entities.""" - return f"unifi-remove-{self.config_entry.entry_id}" - @property def signal_options_update(self) -> str: """Event specific per UniFi entry to signal new options.""" diff --git a/homeassistant/components/unifi/unifi_client.py b/homeassistant/components/unifi/unifi_client.py deleted file mode 100644 index 6c13bb978523..000000000000 --- a/homeassistant/components/unifi/unifi_client.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Base class for UniFi clients.""" -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo - -from .unifi_entity_base import UniFiBase - - -class UniFiClientBase(UniFiBase): - """Base class for UniFi clients (without device info).""" - - def __init__(self, client, controller) -> None: - """Set up client.""" - super().__init__(client, controller) - - self._is_wired = client.mac not in controller.wireless_clients - self.client = self._item - - @property - def is_wired(self): - """Return if the client is wired. - - Allows disabling logic to keep track of clients affected by UniFi wired bug marking wireless devices as wired. This is useful when running a network not only containing UniFi APs. - """ - if self._is_wired and self.client.mac in self.controller.wireless_clients: - self._is_wired = False - - if self.controller.option_ignore_wired_bug: - return self.client.is_wired - - return self._is_wired - - @property - def unique_id(self): - """Return a unique identifier for this switch.""" - return f"{self.TYPE}-{self.client.mac}" - - @property - def name(self) -> str: - """Return the name of the client.""" - return self.client.name or self.client.hostname - - @property - def available(self) -> bool: - """Return if controller is available.""" - return self.controller.available - - -class UniFiClient(UniFiClientBase): - """Base class for UniFi clients (with device info).""" - - @property - def device_info(self) -> DeviceInfo: - """Return a client description for device registry.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.client.mac)}, - default_manufacturer=self.client.oui, - default_name=self.client.name or self.client.hostname, - ) diff --git a/homeassistant/components/unifi/unifi_entity_base.py b/homeassistant/components/unifi/unifi_entity_base.py deleted file mode 100644 index 11b5eac2d3c2..000000000000 --- a/homeassistant/components/unifi/unifi_entity_base.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Base class for UniFi Network entities.""" -from __future__ import annotations - -from collections.abc import Callable -import logging -from typing import TYPE_CHECKING, Any - -from homeassistant.core import callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity - -if TYPE_CHECKING: - from .controller import UniFiController - -_LOGGER = logging.getLogger(__name__) - - -class UniFiBase(Entity): - """UniFi entity base class.""" - - _attr_should_poll = False - - DOMAIN = "" - TYPE = "" - - def __init__(self, item, controller: UniFiController) -> None: - """Set up UniFi Network entity base. - - Register mac to controller entities to cover disabled entities. - """ - self._item = item - self.controller = controller - self.controller.entities[self.DOMAIN][self.TYPE].add(self.key) - - @property - def key(self) -> Any: - """Return item key.""" - return self._item.mac - - async def async_added_to_hass(self) -> None: - """Entity created.""" - _LOGGER.debug( - "New %s entity %s (%s)", - self.TYPE, - self.entity_id, - self.key, - ) - signals: tuple[tuple[str, Callable[..., Any]], ...] = ( - (self.controller.signal_reachable, self.async_signal_reachable_callback), - (self.controller.signal_options_update, self.options_updated), - (self.controller.signal_remove, self.remove_item), - ) - for signal, method in signals: - self.async_on_remove(async_dispatcher_connect(self.hass, signal, method)) - self._item.register_callback(self.async_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect object when removed.""" - _LOGGER.debug( - "Removing %s entity %s (%s)", - self.TYPE, - self.entity_id, - self.key, - ) - self._item.remove_callback(self.async_update_callback) - self.controller.entities[self.DOMAIN][self.TYPE].remove(self.key) - - @callback - def async_signal_reachable_callback(self) -> None: - """Call when controller connection state change.""" - self.async_update_callback() - - @callback - def async_update_callback(self) -> None: - """Update the entity's state.""" - _LOGGER.debug( - "Updating %s entity %s (%s)", - self.TYPE, - self.entity_id, - self.key, - ) - self.async_write_ha_state() - - async def options_updated(self) -> None: - """Config entry options are updated, remove entity if option is disabled.""" - raise NotImplementedError - - async def remove_item(self, keys: set) -> None: - """Remove entity if key is part of set.""" - if self.key not in keys: - return - - if self.registry_entry: - er.async_get(self.hass).async_remove(self.entity_id) - else: - await self.async_remove(force_remove=True) diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 4a37743ac659..931c0fccdf0c 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -244,8 +244,6 @@ async def test_controller_setup( assert controller.mac is None assert controller.signal_reachable == "unifi-reachable-1" - assert controller.signal_update == "unifi-update-1" - assert controller.signal_remove == "unifi-remove-1" assert controller.signal_options_update == "unifi-options-1" assert controller.signal_heartbeat_missed == "unifi-heartbeat-missed" diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 18007998ebab..b4b82166269f 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -14,13 +14,11 @@ from homeassistant.components.unifi.const import ( CONF_ALLOW_UPTIME_SENSORS, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, - DOMAIN as UNIFI_DOMAIN, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util @@ -198,24 +196,6 @@ async def test_bandwidth_sensors( assert hass.states.get("sensor.wired_client_rx") assert hass.states.get("sensor.wired_client_tx") - # Try to add the sensors again, using a signal - - clients_connected = {wired_client["mac"], wireless_client["mac"]} - devices_connected = set() - - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - async_dispatcher_send( - hass, - controller.signal_update, - clients_connected, - devices_connected, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 5 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 - @pytest.mark.parametrize( ("initial_uptime", "event_uptime", "new_uptime"), @@ -311,24 +291,6 @@ async def test_uptime_sensors( assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.client1_uptime") - # Try to add the sensors again, using a signal - - clients_connected = {uptime_client["mac"]} - devices_connected = set() - - controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - async_dispatcher_send( - hass, - controller.signal_update, - clients_connected, - devices_connected, - ) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == 2 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 - async def test_remove_sensors( hass: HomeAssistant, diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 76fa42ad4f00..6ea52c95a9fc 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -30,7 +30,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt @@ -719,11 +718,6 @@ async def test_switches( assert aioclient_mock.call_count == 14 assert aioclient_mock.mock_calls[13][2] == {"enabled": True} - # Make sure no duplicates arise on generic signal update - async_dispatcher_send(hass, controller.signal_update) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 3 - async def test_remove_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket From 56454c8580739575b261fb0d65aed18a997f882f Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sat, 11 Mar 2023 14:27:33 -0500 Subject: [PATCH 0394/1058] Reconnect on any ScreenLogic exception (#89269) Co-authored-by: J. Nick Koston --- homeassistant/components/screenlogic/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index fad4dc6509b1..ad2f9c64f3ee 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -159,11 +159,9 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): """Fetch data from the Screenlogic gateway.""" try: await self._async_update_configured_data() - except ScreenLogicError as error: - _LOGGER.warning("Update error - attempting reconnect: %s", error) + except (ScreenLogicError, ScreenLogicWarning) as ex: + _LOGGER.warning("Update error - attempting reconnect: %s", ex) await self._async_reconnect_update_data() - except ScreenLogicWarning as warn: - raise UpdateFailed(f"Incomplete update: {warn}") from warn return None From 8bd43760b60a29b75acdfb5b36db3097235f3484 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 09:54:55 -1000 Subject: [PATCH 0395/1058] Deduplicate event_types in the events table (#89465) * Deduplicate event_types in the events table * Deduplicate event_types in the events table * more fixes * adjust * adjust * fix product * fix tests * adjust * migrate * migrate * migrate * more test fixes * more test fixes * fix * migration test * adjust * speed up * fix index * fix more tests * handle db failure * preload * tweak * adjust * fix stale docs strings, remove dead code * refactor * fix slow tests * coverage * self join to resolve query performance * fix typo * no need for quiet * no need to drop index already dropped * remove index that will never be used * drop index sooner as we no longer use it * Revert "remove index that will never be used" This reverts commit 461aad2c52d7d6c2c6ca1df4cb30b77c52027d46. * typo --- .../components/logbook/queries/common.py | 11 +- .../components/logbook/queries/devices.py | 5 +- .../components/logbook/queries/entities.py | 5 +- .../logbook/queries/entities_and_devices.py | 11 +- homeassistant/components/recorder/core.py | 57 ++++++++- .../components/recorder/db_schema.py | 37 +++++- .../components/recorder/migration.py | 58 +++++++++ homeassistant/components/recorder/purge.py | 26 ++++ homeassistant/components/recorder/queries.py | 66 +++++++++++ .../recorder/table_managers/event_types.py | 87 ++++++++++++++ homeassistant/components/recorder/tasks.py | 16 +++ .../history/test_init_db_schema_30.py | 4 +- .../db_schema_23_with_newer_columns.py | 19 +++ tests/components/recorder/db_schema_28.py | 43 +++++++ tests/components/recorder/db_schema_30.py | 22 ++++ .../recorder/test_history_db_schema_30.py | 4 +- tests/components/recorder/test_init.py | 69 ++++++++--- tests/components/recorder/test_migrate.py | 78 +++++++++++- tests/components/recorder/test_models.py | 3 + tests/components/recorder/test_purge.py | 112 ++++++++++++++++++ .../components/recorder/test_v32_migration.py | 11 +- tests/conftest.py | 20 ++++ 22 files changed, 725 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/event_types.py diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index a0c8ddbdda20..8645c8f68cbd 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -17,10 +17,12 @@ from homeassistant.components.recorder.db_schema import ( STATES_CONTEXT_ID_BIN_INDEX, EventData, Events, + EventTypes, StateAttributes, States, ) from homeassistant.components.recorder.filters import like_domain_matchers +from homeassistant.components.recorder.queries import select_event_type_ids from ..const import ALWAYS_CONTINUOUS_DOMAINS, CONDITIONALLY_CONTINUOUS_DOMAINS @@ -44,7 +46,7 @@ PSEUDO_EVENT_STATE_CHANGED: Final = None EVENT_COLUMNS = ( Events.event_id.label("event_id"), - Events.event_type.label("event_type"), + EventTypes.event_type.label("event_type"), Events.event_data.label("event_data"), Events.time_fired_ts.label("time_fired_ts"), Events.context_id_bin.label("context_id_bin"), @@ -115,7 +117,8 @@ def select_events_context_id_subquery( return ( select(Events.context_id_bin) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.event_type.in_(event_types)) + .where(Events.event_type_id.in_(select_event_type_ids(event_types))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) @@ -147,7 +150,8 @@ def select_events_without_states( return ( select(*EVENT_ROWS_NO_STATES, NOT_CONTEXT_ONLY) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.event_type.in_(event_types)) + .where(Events.event_type_id.in_(select_event_type_ids(event_types))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) @@ -182,6 +186,7 @@ def legacy_select_events_context_id( .outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) .where(Events.context_id_bin == context_id_bin) ) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index d84a53431089..687c48b89212 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -13,6 +13,7 @@ from homeassistant.components.recorder.db_schema import ( DEVICE_ID_IN_EVENT, EventData, Events, + EventTypes, States, ) @@ -60,7 +61,9 @@ def _apply_devices_context_union( select_events_context_only() .select_from(devices_cte) .outerjoin(Events, devices_cte.c.context_id_bin == Events.context_id_bin) - ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, (Events.data_id == EventData.data_id)), + ), apply_states_context_hints( select_states_context_only() .select_from(devices_cte) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 10ca6fad1349..e0ae32b6694d 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -15,6 +15,7 @@ from homeassistant.components.recorder.db_schema import ( OLD_ENTITY_ID_IN_EVENT, EventData, Events, + EventTypes, States, ) @@ -78,7 +79,9 @@ def _apply_entities_context_union( select_events_context_only() .select_from(entities_cte) .outerjoin(Events, entities_cte.c.context_id_bin == Events.context_id_bin) - ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, (Events.data_id == EventData.data_id)) + ), apply_states_context_hints( select_states_context_only() .select_from(entities_cte) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index b4a1c7bc9f8c..677feddda848 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -8,7 +8,12 @@ from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select -from homeassistant.components.recorder.db_schema import EventData, Events, States +from homeassistant.components.recorder.db_schema import ( + EventData, + Events, + EventTypes, + States, +) from .common import ( apply_events_context_hints, @@ -80,7 +85,9 @@ def _apply_entities_devices_context_union( .outerjoin( Events, devices_entities_cte.c.context_id_bin == Events.context_id_bin ) - ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, (Events.data_id == EventData.data_id)), + ), apply_states_context_hints( select_states_context_only() .select_from(devices_entities_cte) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7e3f08d7abd7..97d72c7f85cf 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -61,6 +61,7 @@ from .db_schema import ( Base, EventData, Events, + EventTypes, StateAttributes, States, Statistics, @@ -81,8 +82,10 @@ from .queries import ( find_shared_data_id, get_shared_attributes, get_shared_event_datas, + has_event_type_to_migrate, ) from .run_history import RunHistory +from .table_managers.event_types import EventTypeManager from .tasks import ( AdjustLRUSizeTask, AdjustStatisticsTask, @@ -92,6 +95,7 @@ from .tasks import ( ContextIDMigrationTask, DatabaseLockTask, EventTask, + EventTypeIDMigrationTask, ImportStatisticsTask, KeepAliveTask, PerodicCleanupTask, @@ -135,6 +139,7 @@ EXPIRE_AFTER_COMMITS = 120 STATE_ATTRIBUTES_ID_CACHE_SIZE = 2048 EVENT_DATA_ID_CACHE_SIZE = 2048 + SHUTDOWN_TASK = object() COMMIT_TASK = CommitTask() @@ -209,6 +214,7 @@ class Recorder(threading.Thread): self._old_states: dict[str | None, States] = {} self._state_attributes_ids: LRU = LRU(STATE_ATTRIBUTES_ID_CACHE_SIZE) self._event_data_ids: LRU = LRU(EVENT_DATA_ID_CACHE_SIZE) + self.event_type_manager = EventTypeManager() self._pending_state_attributes: dict[str, StateAttributes] = {} self._pending_event_data: dict[str, EventData] = {} self._pending_expunge: list[States] = [] @@ -688,10 +694,26 @@ class Recorder(threading.Thread): _LOGGER.debug("Recorder processing the queue") self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) - self.queue_task(ContextIDMigrationTask()) + self._activate_table_managers_or_migrate() self._run_event_loop() self._shutdown() + def _activate_table_managers_or_migrate(self) -> None: + """Activate the table managers or schedule migrations.""" + # Currently we always check if context ids need to be migrated + # since there are multiple tables. This could be optimized + # to check both the states and events table to see if there + # are any missing and avoid inserting the task but it currently + # is not needed since there is no dependent code branching + # on the result of the migration. + self.queue_task(ContextIDMigrationTask()) + with session_scope(session=self.get_session()) as session: + if session.execute(has_event_type_to_migrate()).scalar(): + self.queue_task(EventTypeIDMigrationTask()) + else: + _LOGGER.debug("Activating event type manager as all data is migrated") + self.event_type_manager.active = True + def _run_event_loop(self) -> None: """Run the event loop for the recorder.""" # Use a session for the event read loop @@ -724,8 +746,10 @@ class Recorder(threading.Thread): else: non_state_change_events.append(event_) + assert self.event_session is not None self._pre_process_state_change_events(state_change_events) self._pre_process_non_state_change_events(non_state_change_events) + self.event_type_manager.load(non_state_change_events, self.event_session) def _pre_process_state_change_events(self, events: list[Event]) -> None: """Load startup state attributes from the database. @@ -944,13 +968,30 @@ class Recorder(threading.Thread): def _process_non_state_changed_event_into_session(self, event: Event) -> None: """Process any event into the session except state changed.""" - assert self.event_session is not None + event_session = self.event_session + assert event_session is not None dbevent = Events.from_event(event) + + # Map the event_type to the EventTypes table + event_type_manager = self.event_type_manager + if pending_event_types := event_type_manager.get_pending(event.event_type): + dbevent.event_type_rel = pending_event_types + elif event_type_id := event_type_manager.get(event.event_type, event_session): + dbevent.event_type_id = event_type_id + else: + event_types = EventTypes(event_type=event.event_type) + event_type_manager.add_pending(event_types) + event_session.add(event_types) + dbevent.event_type_rel = event_types + if not event.data: - self.event_session.add(dbevent) + event_session.add(dbevent) return + if not (shared_data_bytes := self._serialize_event_data_from_event(event)): return + + # Map the event data to the EventData table shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit if pending_event_data := self._pending_event_data.get(shared_data): @@ -969,9 +1010,9 @@ class Recorder(threading.Thread): dbevent.event_data_rel = self._pending_event_data[ shared_data ] = dbevent_data - self.event_session.add(dbevent_data) + event_session.add(dbevent_data) - self.event_session.add(dbevent) + event_session.add(dbevent) def _serialize_state_attributes_from_event(self, event: Event) -> bytes | None: """Serialize state changed event data.""" @@ -1096,6 +1137,7 @@ class Recorder(threading.Thread): for event_data in self._pending_event_data.values(): self._event_data_ids[event_data.shared_data] = event_data.data_id self._pending_event_data = {} + self.event_type_manager.post_commit_pending() # Expire is an expensive operation (frequently more expensive # than the flush and commit itself) so we only @@ -1122,6 +1164,7 @@ class Recorder(threading.Thread): self._event_data_ids.clear() self._pending_state_attributes.clear() self._pending_event_data.clear() + self.event_type_manager.reset() if not self.event_session: return @@ -1152,6 +1195,10 @@ class Recorder(threading.Thread): """Migrate context ids if needed.""" return migration.migrate_context_ids(self) + def _migrate_event_type_ids(self) -> bool: + """Migrate event type ids if needed.""" + return migration.migrate_event_type_ids(self) + def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 01b15dd37818..9499e9d4e31e 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,12 +68,13 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 36 +SCHEMA_VERSION = 37 _LOGGER = logging.getLogger(__name__) TABLE_EVENTS = "events" TABLE_EVENT_DATA = "event_data" +TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" TABLE_RECORDER_RUNS = "recorder_runs" @@ -93,6 +94,7 @@ ALL_TABLES = [ TABLE_STATE_ATTRIBUTES, TABLE_EVENTS, TABLE_EVENT_DATA, + TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, @@ -176,7 +178,9 @@ class Events(Base): __table_args__ = ( # Used for fetching events at a specific time # see logbook - Index("ix_events_event_type_time_fired_ts", "event_type", "time_fired_ts"), + Index( + "ix_events_event_type_id_time_fired_ts", "event_type_id", "time_fired_ts" + ), Index( EVENTS_CONTEXT_ID_BIN_INDEX, "context_id_bin", @@ -187,7 +191,9 @@ class Events(Base): ) __tablename__ = TABLE_EVENTS event_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - event_type: Mapped[str | None] = mapped_column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_type: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_EVENT_EVENT_TYPE) + ) # no longer used event_data: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) @@ -220,13 +226,17 @@ class Events(Base): context_parent_id_bin: Mapped[bytes | None] = mapped_column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) + event_type_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("event_types.event_type_id"), index=True + ) event_data_rel: Mapped[EventData | None] = relationship("EventData") + event_type_rel: Mapped[EventTypes | None] = relationship("EventTypes") def __repr__(self) -> str: """Return string representation of instance for debugging.""" return ( "" ) @@ -247,7 +257,7 @@ class Events(Base): def from_event(event: Event) -> Events: """Create an event database object from a native event.""" return Events( - event_type=event.event_type, + event_type=None, event_data=None, origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), time_fired=None, @@ -330,6 +340,23 @@ class EventData(Base): return {} +class EventTypes(Base): + """Event type history.""" + + __table_args__ = (_DEFAULT_TABLE_ARGS,) + __tablename__ = TABLE_EVENT_TYPES + event_type_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + event_type: Mapped[str | None] = mapped_column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class States(Base): """State change history.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index a5ff110e57ec..e7a34f22fccd 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -35,6 +35,7 @@ from .db_schema import ( TABLE_STATES, Base, Events, + EventTypes, SchemaChanges, States, Statistics, @@ -44,6 +45,7 @@ from .db_schema import ( ) from .models import process_timestamp from .queries import ( + find_event_type_to_migrate, find_events_context_ids_to_migrate, find_states_context_ids_to_migrate, ) @@ -978,6 +980,11 @@ def _apply_update( # noqa: C901 ) _create_index(session_maker, "events", "ix_events_context_id_bin") _create_index(session_maker, "states", "ix_states_context_id_bin") + elif new_version == 37: + _add_columns(session_maker, "events", [f"event_type_id {big_int}"]) + _create_index(session_maker, "events", "ix_events_event_type_id") + _drop_index(session_maker, "events", "ix_events_event_type_time_fired_ts") + _create_index(session_maker, "events", "ix_events_event_type_id_time_fired_ts") else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -1288,6 +1295,57 @@ def migrate_context_ids(instance: Recorder) -> bool: return is_done +def migrate_event_type_ids(instance: Recorder) -> bool: + """Migrate event_type to event_type_ids.""" + session_maker = instance.get_session + _LOGGER.debug("Migrating event_types") + event_type_manager = instance.event_type_manager + with session_scope(session=session_maker()) as session: + if events := session.execute(find_event_type_to_migrate()).all(): + event_types = {event_type for _, event_type in events} + event_type_to_id = event_type_manager.get_many(event_types, session) + if missing_event_types := { + event_type + for event_type, event_id in event_type_to_id.items() + if event_id is None + }: + missing_db_event_types = [ + EventTypes(event_type=event_type) + for event_type in missing_event_types + ] + session.add_all(missing_db_event_types) + session.flush() # Assign ids + for db_event_type in missing_db_event_types: + # We cannot add the assigned ids to the event_type_manager + # because the commit could get rolled back + assert db_event_type.event_type is not None + event_type_to_id[ + db_event_type.event_type + ] = db_event_type.event_type_id + + session.execute( + update(Events), + [ + { + "event_id": event_id, + "event_type": None, + "event_type_id": event_type_to_id[event_type], + } + for event_id, event_type in events + ], + ) + + # If there is more work to do return False + # so that we can be called again + is_done = not events + + if is_done: + instance.event_type_manager.active = True + + _LOGGER.debug("Migrating event_types done=%s", is_done) + return is_done + + def _initialize_database(session: Session) -> bool: """Initialize a new database. diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 7ae63ef026b5..368a6ccdf1c6 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -24,12 +24,14 @@ from .queries import ( data_ids_exist_in_events_with_fast_in_distinct, delete_event_data_rows, delete_event_rows, + delete_event_types_rows, delete_recorder_runs_rows, delete_states_attributes_rows, delete_states_rows, delete_statistics_runs_rows, delete_statistics_short_term_rows, disconnect_states_rows, + find_event_types_to_purge, find_events_to_purge, find_latest_statistics_runs_run_id, find_legacy_event_state_and_attributes_and_data_ids_to_purge, @@ -109,6 +111,11 @@ def purge_old_data( _LOGGER.debug("Cleanup filtered data hasn't fully completed yet") return False + # This purge cycle is finished, clean up old event types and + # recorder runs + if instance.event_type_manager.active: + _purge_old_event_types(instance, session) + _purge_old_recorder_runs(instance, session, purge_before) if repack: repack_database(instance) @@ -564,6 +571,25 @@ def _purge_old_recorder_runs( _LOGGER.debug("Deleted %s recorder_runs", deleted_rows) +def _purge_old_event_types(instance: Recorder, session: Session) -> None: + """Purge all old event types.""" + # Event types is small, no need to batch run it + purge_event_types = set() + event_type_ids = set() + for event_type_id, event_type in session.execute(find_event_types_to_purge()): + purge_event_types.add(event_type) + event_type_ids.add(event_type_id) + + if not event_type_ids: + return + + deleted_rows = session.execute(delete_event_types_rows(event_type_ids)) + _LOGGER.debug("Deleted %s event types", deleted_rows) + + # Evict any entries in the event_type cache referring to a purged state + instance.event_type_manager.evict_purged(purge_event_types) + + def _purge_filtered_data(instance: Recorder, session: Session) -> bool: """Remove filtered states and events that shouldn't be in the database.""" _LOGGER.debug("Cleanup filtered data") diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 217b7ed11bbc..d0672e615817 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -12,6 +12,7 @@ from .const import SQLITE_MAX_BIND_VARS from .db_schema import ( EventData, Events, + EventTypes, RecorderRuns, StateAttributes, States, @@ -20,6 +21,17 @@ from .db_schema import ( ) +def select_event_type_ids(event_types: tuple[str, ...]) -> Select: + """Generate a select for event type ids. + + This query is intentionally not a lambda statement as it is used inside + other lambda statements. + """ + return select(EventTypes.event_type_id).where( + EventTypes.event_type.in_(event_types) + ) + + def get_shared_attributes(hashes: list[int]) -> StatementLambdaElement: """Load shared attributes from the database.""" return lambda_stmt( @@ -38,6 +50,15 @@ def get_shared_event_datas(hashes: list[int]) -> StatementLambdaElement: ) +def find_event_type_ids(event_types: Iterable[str]) -> StatementLambdaElement: + """Find an event_type id by event_type.""" + return lambda_stmt( + lambda: select(EventTypes.event_type_id, EventTypes.event_type).filter( + EventTypes.event_type.in_(event_types) + ) + ) + + def find_shared_attributes_id( data_hash: int, shared_attrs: str ) -> StatementLambdaElement: @@ -683,6 +704,25 @@ def find_events_context_ids_to_migrate() -> StatementLambdaElement: ) +def find_event_type_to_migrate() -> StatementLambdaElement: + """Find events event_type to migrate.""" + return lambda_stmt( + lambda: select( + Events.event_id, + Events.event_type, + ) + .filter(Events.event_type_id.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def has_event_type_to_migrate() -> StatementLambdaElement: + """Check if there are event_types to migrate.""" + return lambda_stmt( + lambda: select(Events.event_id).filter(Events.event_type_id.is_(None)).limit(1) + ) + + def find_states_context_ids_to_migrate() -> StatementLambdaElement: """Find events context_ids to migrate.""" return lambda_stmt( @@ -695,3 +735,29 @@ def find_states_context_ids_to_migrate() -> StatementLambdaElement: .filter(States.context_id_bin.is_(None)) .limit(SQLITE_MAX_BIND_VARS) ) + + +def find_event_types_to_purge() -> StatementLambdaElement: + """Find event_type_ids to purge.""" + return lambda_stmt( + lambda: select(EventTypes.event_type_id, EventTypes.event_type).where( + EventTypes.event_type_id.not_in( + select(EventTypes.event_type_id).join( + used_event_type_ids := select( + distinct(Events.event_type_id).label("used_event_type_id") + ).subquery(), + EventTypes.event_type_id + == used_event_type_ids.c.used_event_type_id, + ) + ) + ) + ) + + +def delete_event_types_rows(event_type_ids: Iterable[int]) -> StatementLambdaElement: + """Delete EventTypes rows.""" + return lambda_stmt( + lambda: delete(EventTypes) + .where(EventTypes.event_type_id.in_(event_type_ids)) + .execution_options(synchronize_session=False) + ) diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py new file mode 100644 index 000000000000..15dfff28b881 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -0,0 +1,87 @@ +"""Support managing EventTypes.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import cast + +from lru import LRU # pylint: disable=no-name-in-module +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event + +from ..db_schema import EventTypes +from ..queries import find_event_type_ids + +CACHE_SIZE = 2048 + + +class EventTypeManager: + """Manage the EventTypes table.""" + + def __init__(self) -> None: + """Initialize the event type manager.""" + self._id_map: dict[str, int] = LRU(CACHE_SIZE) + self._pending: dict[str, EventTypes] = {} + self.active = False + + def load(self, events: list[Event], session: Session) -> None: + """Load the event_type to event_type_ids mapping into memory.""" + self.get_many( + (event.event_type for event in events if event.event_type is not None), + session, + ) + + def get(self, event_type: str, session: Session) -> int | None: + """Resolve event_type to the event_type_id.""" + return self.get_many((event_type,), session)[event_type] + + def get_many( + self, event_types: Iterable[str], session: Session + ) -> dict[str, int | None]: + """Resolve event_types to event_type_ids.""" + results: dict[str, int | None] = {} + missing: list[str] = [] + for event_type in event_types: + if (event_type_id := self._id_map.get(event_type)) is None: + missing.append(event_type) + + results[event_type] = event_type_id + + if not missing: + return results + + with session.no_autoflush: + for event_type_id, event_type in session.execute( + find_event_type_ids(missing) + ): + results[event_type] = self._id_map[event_type] = cast( + int, event_type_id + ) + + return results + + def get_pending(self, event_type: str) -> EventTypes | None: + """Get pending EventTypes that have not be assigned ids yet.""" + return self._pending.get(event_type) + + def add_pending(self, db_event_type: EventTypes) -> None: + """Add a pending EventTypes that will be committed at the next interval.""" + assert db_event_type.event_type is not None + event_type: str = db_event_type.event_type + self._pending[event_type] = db_event_type + + def post_commit_pending(self) -> None: + """Call after commit to load the event_type_ids of the new EventTypes into the LRU.""" + for event_type, db_event_types in self._pending.items(): + self._id_map[event_type] = db_event_types.event_type_id + self._pending.clear() + + def reset(self) -> None: + """Reset the event manager after the database has been reset or changed.""" + self._id_map.clear() + self._pending.clear() + + def evict_purged(self, event_types: Iterable[str]) -> None: + """Evict purged event_types from the cache when they are no longer used.""" + for event_type in event_types: + self._id_map.pop(event_type, None) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 37a027725726..81a105742b4d 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -356,3 +356,19 @@ class ContextIDMigrationTask(RecorderTask): if not instance._migrate_context_ids(): # pylint: disable=[protected-access] # Schedule a new migration task if this one didn't finish instance.queue_task(ContextIDMigrationTask()) + + +@dataclass +class EventTypeIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate event type ids.""" + + commit_before = True + # We have to commit before to make sure there are + # no new pending event_types about to be added to + # the db since this happens live + + def run(self, instance: Recorder) -> None: + """Run event type id migration task.""" + if not instance._migrate_event_type_ids(): # pylint: disable=[protected-access] + # Schedule a new migration task if this one didn't finish + instance.queue_task(EventTypeIDMigrationTask()) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 392c06f84337..7c1b7a5e97b0 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -69,7 +69,9 @@ def db_schema_30(): with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( + ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + core, "EventData", old_db_schema.EventData + ), patch.object( core, "States", old_db_schema.States ), patch.object( core, "Events", old_db_schema.Events diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index 0cd3f4149018..c8c87ca82dd0 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -69,10 +69,12 @@ TABLE_STATISTICS_META = "statistics_meta" TABLE_STATISTICS_RUNS = "statistics_runs" TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" TABLE_EVENT_DATA = "event_data" +TABLE_EVENT_TYPES = "event_types" ALL_TABLES = [ TABLE_STATES, TABLE_EVENTS, + TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, @@ -141,9 +143,13 @@ class Events(Base): # type: ignore context_parent_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) # *** Not originally in v23, only added for recorder to startup ok + event_type_id = Column( + Integer, ForeignKey("event_types.event_type_id"), index=True + ) # *** Not originally in v23, only added for recorder to startup ok event_data_rel = relationship( "EventData" ) # *** Not originally in v23, only added for recorder to startup ok + event_type_rel = relationship("EventTypes") def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -204,6 +210,19 @@ class EventData(Base): # type: ignore[misc,valid-type] shared_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) +# *** Not originally in v23, only added for recorder to startup ok +# This is not being tested by the v23 statistics migration tests +class EventTypes(Base): # type: ignore[misc,valid-type] + """Event type history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENT_TYPES + event_type_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + + class States(Base): # type: ignore """State change history.""" diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index 422f317a6f10..f7152cec508b 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -21,6 +21,7 @@ from sqlalchemy import ( Identity, Index, Integer, + LargeBinary, SmallInteger, String, Text, @@ -54,6 +55,7 @@ DB_TIMEZONE = "+00:00" TABLE_EVENTS = "events" TABLE_EVENT_DATA = "event_data" +TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" TABLE_RECORDER_RUNS = "recorder_runs" @@ -68,6 +70,7 @@ ALL_TABLES = [ TABLE_STATE_ATTRIBUTES, TABLE_EVENTS, TABLE_EVENT_DATA, + TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, @@ -98,6 +101,11 @@ DOUBLE_TYPE = ( ) EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote] EVENT_ORIGIN_TO_IDX = {origin: idx for idx, origin in enumerate(EVENT_ORIGIN_ORDER)} +CONTEXT_ID_BIN_MAX_LENGTH = 16 +EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" +STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" + +TIMESTAMP_TYPE = DOUBLE_TYPE class Events(Base): # type: ignore[misc,valid-type] @@ -107,6 +115,12 @@ class Events(Base): # type: ignore[misc,valid-type] # Used for fetching events at a specific time # see logbook Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + Index( + EVENTS_CONTEXT_ID_BIN_INDEX, + "context_id_bin", + mysql_length=CONTEXT_ID_BIN_MAX_LENGTH, + mariadb_length=CONTEXT_ID_BIN_MAX_LENGTH, + ), {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, ) __tablename__ = TABLE_EVENTS @@ -116,11 +130,27 @@ class Events(Base): # type: ignore[misc,valid-type] origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) # no longer used origin_idx = Column(SmallInteger) time_fired = Column(DATETIME_TYPE, index=True) + time_fired_ts = Column( + TIMESTAMP_TYPE, index=True + ) # *** Not originally in v30, only added for recorder to startup ok context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) data_id = Column(Integer, ForeignKey("event_data.data_id"), index=True) + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + event_type_id = Column( + Integer, ForeignKey("event_types.event_type_id"), index=True + ) # *** Not originally in v28, only added for recorder to startup ok event_data_rel = relationship("EventData") + event_type_rel = relationship("EventTypes") def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -214,6 +244,19 @@ class EventData(Base): # type: ignore[misc,valid-type] return {} +# *** Not originally in v28, only added for recorder to startup ok +# This is not being tested by the v28 statistics migration tests +class EventTypes(Base): # type: ignore[misc,valid-type] + """Event type history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENT_TYPES + event_type_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + + class States(Base): # type: ignore[misc,valid-type] """State change history.""" diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 7862ad061429..ed9fb89e4644 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -64,6 +64,7 @@ _LOGGER = logging.getLogger(__name__) TABLE_EVENTS = "events" TABLE_EVENT_DATA = "event_data" +TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" TABLE_RECORDER_RUNS = "recorder_runs" @@ -78,6 +79,7 @@ ALL_TABLES = [ TABLE_STATE_ATTRIBUTES, TABLE_EVENTS, TABLE_EVENT_DATA, + TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLE_STATISTICS, @@ -212,6 +214,9 @@ class Events(Base): # type: ignore[misc,valid-type] origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) # no longer used for new rows origin_idx = Column(SmallInteger) time_fired = Column(DATETIME_TYPE, index=True) + time_fired_ts = Column( + TIMESTAMP_TYPE, index=True + ) # *** Not originally in v30, only added for recorder to startup ok context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) @@ -225,7 +230,11 @@ class Events(Base): # type: ignore[misc,valid-type] context_parent_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) # *** Not originally in v30, only added for recorder to startup ok + event_type_id = Column( + Integer, ForeignKey("event_types.event_type_id"), index=True + ) # *** Not originally in v30, only added for recorder to startup ok event_data_rel = relationship("EventData") + event_type_rel = relationship("EventTypes") def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -322,6 +331,19 @@ class EventData(Base): # type: ignore[misc,valid-type] return {} +# *** Not originally in v30, only added for recorder to startup ok +# This is not being tested by the v30 statistics migration tests +class EventTypes(Base): # type: ignore[misc,valid-type] + """Event type history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENT_TYPES + event_type_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + + class States(Base): # type: ignore[misc,valid-type] """State change history.""" diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index e0f24b35f97e..ae37d50f03bb 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -65,7 +65,9 @@ def db_schema_30(): with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( + ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + core, "EventData", old_db_schema.EventData + ), patch.object( core, "States", old_db_schema.States ), patch.object( core, "Events", old_db_schema.Events diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index a810d74556bb..c46d77677af1 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -39,12 +39,14 @@ from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, EventData, Events, + EventTypes, RecorderRuns, StateAttributes, States, StatisticsRuns, ) from homeassistant.components.recorder.models import process_timestamp +from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.services import ( SERVICE_DISABLE, SERVICE_ENABLE, @@ -483,16 +485,19 @@ def test_saving_event(hass_recorder: Callable[..., HomeAssistant]) -> None: events: list[Event] = [] with session_scope(hass=hass) as session: - for select_event, event_data in ( - session.query(Events, EventData) - .filter_by(event_type=event_type) + for select_event, event_data, event_types in ( + session.query(Events, EventData, EventTypes) + .filter(Events.event_type_id.in_(select_event_type_ids((event_type,)))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, Events.data_id == EventData.data_id) ): select_event = cast(Events, select_event) event_data = cast(EventData, event_data) + event_types = cast(EventTypes, event_types) native_event = select_event.to_native() native_event.data = event_data.to_native() + native_event.event_type = event_types.event_type events.append(native_event) db_event = events[0] @@ -555,15 +560,19 @@ def _add_events(hass, events): with session_scope(hass=hass) as session: events = [] - for event, event_data in session.query(Events, EventData).outerjoin( - EventData, Events.data_id == EventData.data_id + for event, event_data, event_types in ( + session.query(Events, EventData, EventTypes) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + .outerjoin(EventData, Events.data_id == EventData.data_id) ): event = cast(Events, event) event_data = cast(EventData, event_data) + event_types = cast(EventTypes, event_types) native_event = event.to_native() if event_data: native_event.data = event_data.to_native() + native_event.event_type = event_types.event_type events.append(native_event) return events @@ -1349,7 +1358,11 @@ def test_service_disable_events_not_recording( event = events[0] with session_scope(hass=hass) as session: - db_events = list(session.query(Events).filter_by(event_type=event_type)) + db_events = list( + session.query(Events) + .filter(Events.event_type_id.in_(select_event_type_ids((event_type,)))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) + ) assert len(db_events) == 0 assert hass.services.call( @@ -1369,16 +1382,19 @@ def test_service_disable_events_not_recording( db_events = [] with session_scope(hass=hass) as session: - for select_event, event_data in ( - session.query(Events, EventData) - .filter_by(event_type=event_type) + for select_event, event_data, event_types in ( + session.query(Events, EventData, EventTypes) + .filter(Events.event_type_id.in_(select_event_type_ids((event_type,)))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, Events.data_id == EventData.data_id) ): select_event = cast(Events, select_event) event_data = cast(EventData, event_data) + event_types = cast(EventTypes, event_types) native_event = select_event.to_native() native_event.data = event_data.to_native() + native_event.event_type = event_types.event_type db_events.append(native_event) assert len(db_events) == 1 @@ -1558,6 +1574,7 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: hass = hass_recorder( {"include": {"domains": "hello"}, "exclude": {"domains": "hidden_domain"}} ) + event_types = ("hello",) for idx, data in enumerate( ( @@ -1572,7 +1589,11 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: wait_recording_done(hass) with session_scope(hass=hass) as session: - db_events = list(session.query(Events).filter_by(event_type="hello")) + db_events = list( + session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(event_types)) + ) + ) assert len(db_events) == idx + 1, data for data in ( @@ -1583,7 +1604,11 @@ def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: wait_recording_done(hass) with session_scope(hass=hass) as session: - db_events = list(session.query(Events).filter_by(event_type="hello")) + db_events = list( + session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(event_types)) + ) + ) # Keep referring idx + 1, as no new events are being added assert len(db_events) == idx + 1, data @@ -1608,10 +1633,16 @@ async def test_database_lock_and_unlock( } await async_setup_recorder_instance(hass, config) await hass.async_block_till_done() + event_type = "EVENT_TEST" + event_types = (event_type,) def _get_db_events(): with session_scope(hass=hass) as session: - return list(session.query(Events).filter_by(event_type=event_type)) + return list( + session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(event_types)) + ) + ) instance = get_instance(hass) @@ -1619,7 +1650,6 @@ async def test_database_lock_and_unlock( assert not await instance.lock_database() - event_type = "EVENT_TEST" event_data = {"test_attr": 5, "test_attr_10": "nice"} hass.bus.async_fire(event_type, event_data) task = asyncio.create_task(async_wait_recording_done(hass)) @@ -1658,10 +1688,16 @@ async def test_database_lock_and_overflow( } await async_setup_recorder_instance(hass, config) await hass.async_block_till_done() + event_type = "EVENT_TEST" + event_types = (event_type,) def _get_db_events(): with session_scope(hass=hass) as session: - return list(session.query(Events).filter_by(event_type=event_type)) + return list( + session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(event_types)) + ) + ) instance = get_instance(hass) @@ -1670,7 +1706,6 @@ async def test_database_lock_and_overflow( ): await instance.lock_database() - event_type = "EVENT_TEST" event_data = {"test_attr": 5, "test_attr_10": "nice"} hass.bus.fire(event_type, event_data) @@ -1793,9 +1828,11 @@ def test_deduplication_event_data_inside_commit_interval( wait_recording_done(hass) with session_scope(hass=hass) as session: + event_types = ("this_event",) events = list( session.query(Events) - .filter(Events.event_type == "this_event") + .filter(Events.event_type_id.in_(select_event_type_ids(event_types))) + .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .outerjoin(EventData, (Events.data_id == EventData.data_id)) ) assert len(events) == 20 diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 730d90e14ba4..062013e72800 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -25,10 +25,15 @@ from homeassistant.components.recorder import db_schema, migration from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, Events, + EventTypes, RecorderRuns, States, ) -from homeassistant.components.recorder.tasks import ContextIDMigrationTask +from homeassistant.components.recorder.queries import select_event_type_ids +from homeassistant.components.recorder.tasks import ( + ContextIDMigrationTask, + EventTypeIDMigrationTask, +) from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper @@ -688,3 +693,74 @@ async def test_migrate_context_ids( assert invalid_context_id_event["context_id_bin"] == b"\x00" * 16 assert invalid_context_id_event["context_user_id_bin"] is None assert invalid_context_id_event["context_parent_id_bin"] is None + + +@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +async def test_migrate_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate event_types to the EventTypes table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1677721632.452529, + ), + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1677721632.552529, + ), + Events( + event_type="event_type_two", + origin_idx=0, + time_fired_ts=1677721632.552529, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EventTypeIDMigrationTask()) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events.event_id, Events.time_fired, EventTypes.event_type) + .filter( + Events.event_type_id.in_( + select_event_type_ids( + ( + "event_type_one", + "event_type_two", + ) + ) + ) + ) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .all() + ) + assert len(events) == 3 + result = {} + for event in events: + result.setdefault(event.event_type, []).append( + { + "event_id": event.event_id, + "time_fired": event.time_fired, + "event_type": event.event_type, + } + ) + return result + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type["event_type_two"]) == 1 diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 6f4de420b7b7..df8ffa0d3480 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -31,6 +31,7 @@ def test_from_event_to_db_event() -> None: db_event = Events.from_event(event) dialect = SupportedDialect.MYSQL db_event.event_data = EventData.shared_data_bytes_from_event(event, dialect) + db_event.event_type = event.event_type assert event.as_dict() == db_event.to_native().as_dict() @@ -232,11 +233,13 @@ async def test_event_to_db_model() -> None: db_event = Events.from_event(event) dialect = SupportedDialect.MYSQL db_event.event_data = EventData.shared_data_bytes_from_event(event, dialect) + db_event.event_type = event.event_type native = db_event.to_native() assert native.as_dict() == event.as_dict() native = Events.from_event(event).to_native() event.data = {} + native.event_type = event.event_type assert native.as_dict() == event.as_dict() diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 07c935129e94..fcabb2e83a8a 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -16,6 +16,7 @@ from homeassistant.components.recorder.const import ( from homeassistant.components.recorder.db_schema import ( EventData, Events, + EventTypes, RecorderRuns, StateAttributes, States, @@ -31,6 +32,7 @@ from homeassistant.components.recorder.tasks import PurgeTask from homeassistant.components.recorder.util import session_scope from homeassistant.const import EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -1684,3 +1686,113 @@ async def test_purge_can_mix_legacy_and_new_format( # does not prevent future purges. Its ignored. assert states_with_event_id.count() == 0 assert states_without_event_id.count() == 1 + + +async def test_purge_old_events_purges_the_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting old events purges event type ids.""" + instance = await async_setup_recorder_instance(hass) + assert instance.event_type_manager.active is True + + utcnow = dt_util.utcnow() + five_days_ago = utcnow - timedelta(days=5) + eleven_days_ago = utcnow - timedelta(days=11) + far_past = utcnow - timedelta(days=1000) + event_data = {"test_attr": 5, "test_attr_10": "nice"} + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + event_type_test_auto_purge = EventTypes(event_type="EVENT_TEST_AUTOPURGE") + event_type_test_purge = EventTypes(event_type="EVENT_TEST_PURGE") + event_type_test = EventTypes(event_type="EVENT_TEST") + event_type_unused = EventTypes(event_type="EVENT_TEST_UNUSED") + session.add_all( + ( + event_type_test_auto_purge, + event_type_test_purge, + event_type_test, + event_type_unused, + ) + ) + session.flush() + for _ in range(5): + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + event_type = event_type_test_auto_purge + elif event_id < 4: + timestamp = five_days_ago + event_type = event_type_test_purge + else: + timestamp = utcnow + event_type = event_type_test + + session.add( + Events( + event_type=None, + event_type_id=event_type.event_type_id, + event_data=json_dumps(event_data), + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + return instance.event_type_manager.get_many( + [ + "EVENT_TEST_AUTOPURGE", + "EVENT_TEST_PURGE", + "EVENT_TEST", + "EVENT_TEST_UNUSED", + ], + session, + ) + + event_type_to_id = await instance.async_add_executor_job(_insert_events) + test_event_type_ids = event_type_to_id.values() + with session_scope(hass=hass) as session: + events = session.query(Events).where( + Events.event_type_id.in_(test_event_type_ids) + ) + event_types = session.query(EventTypes).where( + EventTypes.event_type_id.in_(test_event_type_ids) + ) + + assert events.count() == 30 + assert event_types.count() == 4 + + # run purge_old_data() + finished = purge_old_data( + instance, + far_past, + repack=False, + ) + assert finished + assert events.count() == 30 + # We should remove the unused event type + assert event_types.count() == 3 + + assert "EVENT_TEST_UNUSED" not in instance.event_type_manager._id_map + + # we should only have 10 events left since + # only one event type was recorded now + finished = purge_old_data( + instance, + utcnow, + repack=False, + ) + assert finished + assert events.count() == 10 + assert event_types.count() == 1 + + # Purge everything + finished = purge_old_data( + instance, + utcnow + timedelta(seconds=1), + repack=False, + ) + assert finished + assert events.count() == 0 + assert event_types.count() == 0 diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index e31d4472aaff..6fe810758fb0 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from homeassistant.components import recorder from homeassistant.components.recorder import SQLITE_URL_PREFIX, core, statistics +from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.util import session_scope from homeassistant.core import EVENT_STATE_CHANGED, Event, EventOrigin, State from homeassistant.helpers import recorder as recorder_helper @@ -87,7 +88,9 @@ def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventData", old_db_schema.EventData), patch.object( + ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + core, "EventData", old_db_schema.EventData + ), patch.object( core, "States", old_db_schema.States ), patch.object( core, "Events", old_db_schema.Events @@ -117,8 +120,10 @@ def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: wait_recording_done(hass) with session_scope(hass=hass) as session: result = list( - session.query(recorder.db_schema.Events).where( - recorder.db_schema.Events.event_type == "custom_event" + session.query(recorder.db_schema.Events).filter( + recorder.db_schema.Events.event_type_id.in_( + select_event_type_ids(("custom_event",)) + ) ) ) assert len(result) == 1 diff --git a/tests/conftest.py b/tests/conftest.py index ed5a95f1b254..25ee8143829a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1148,6 +1148,16 @@ def enable_migrate_context_ids() -> bool: return False +@pytest.fixture +def enable_migrate_event_type_ids() -> bool: + """Fixture to control enabling of recorder's event type id migration. + + To enable context id migration, tests can be marked with: + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) + """ + return False + + @pytest.fixture def recorder_config() -> dict[str, Any] | None: """Fixture to override recorder config. @@ -1291,6 +1301,7 @@ async def async_setup_recorder_instance( enable_statistics: bool, enable_statistics_table_validation: bool, enable_migrate_context_ids: bool, + enable_migrate_event_type_ids: bool, ) -> AsyncGenerator[RecorderInstanceGenerator, None]: """Yield callable to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel @@ -1309,6 +1320,11 @@ async def async_setup_recorder_instance( migrate_context_ids = ( recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None ) + migrate_event_type_ids = ( + recorder.Recorder._migrate_event_type_ids + if enable_migrate_event_type_ids + else None + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -1325,6 +1341,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.Recorder._migrate_context_ids", side_effect=migrate_context_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_event_type_ids", + side_effect=migrate_event_type_ids, + autospec=True, ): async def async_setup_recorder( From 16b420d660db5ec651beef95994010d351d69b98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 10:37:00 -1000 Subject: [PATCH 0396/1058] Fix get_significant_states_with_session query looking at legacy columns (#89558) --- homeassistant/components/recorder/history.py | 8 +++++--- tests/components/recorder/test_history.py | 21 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index b67790f9a429..a745716757f4 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -282,9 +282,11 @@ def _significant_states_stmt( (States.last_changed_ts == States.last_updated_ts) | States.last_changed_ts.is_(None) ) - stmt += lambda q: q.filter( - (States.last_changed == States.last_updated) | States.last_changed.is_(None) - ) + else: + stmt += lambda q: q.filter( + (States.last_changed == States.last_updated) + | States.last_changed.is_(None) + ) elif significant_changes_only: if schema_version >= 31: stmt += lambda q: q.filter( diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index d082806f3da7..2b4bed072a41 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -209,6 +209,27 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( ) +def test_significant_states_with_session_single_entity( + hass_recorder: Callable[..., HomeAssistant], +) -> None: + """Test get_significant_states_with_session with a single entity.""" + hass = hass_recorder() + hass.states.set("demo.id", "any", {"attr": True}) + hass.states.set("demo.id", "any2", {"attr": True}) + wait_recording_done(hass) + now = dt_util.utcnow() + with session_scope(hass=hass) as session: + states = history.get_significant_states_with_session( + hass, + session, + now - timedelta(days=1), + now, + entity_ids=["demo.id"], + minimal_response=False, + ) + assert len(states["demo.id"]) == 2 + + @pytest.mark.parametrize( ("attributes", "no_attributes", "limit"), [ From 50c31a53555d1e1b03d88f8bd0e864a8d80acc8b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 11:26:30 -1000 Subject: [PATCH 0397/1058] Move legacy database queries and models to prepare for schema v38 (#89532) --- .../components/recorder/history/__init__.py | 22 + .../components/recorder/history/common.py | 10 + .../components/recorder/history/const.py | 23 + .../{history.py => history/legacy.py} | 61 +- homeassistant/components/recorder/models.py | 521 ------------------ .../components/recorder/models/__init__.py | 53 ++ .../components/recorder/models/context.py | 42 ++ .../components/recorder/models/database.py | 33 ++ .../components/recorder/models/legacy.py | 164 ++++++ .../components/recorder/models/state.py | 145 +++++ .../recorder/models/state_attributes.py | 30 + .../components/recorder/models/statistics.py | 89 +++ .../components/recorder/models/time.py | 82 +++ tests/components/recorder/test_history.py | 10 +- tests/components/recorder/test_util.py | 7 +- 15 files changed, 724 insertions(+), 568 deletions(-) create mode 100644 homeassistant/components/recorder/history/__init__.py create mode 100644 homeassistant/components/recorder/history/common.py create mode 100644 homeassistant/components/recorder/history/const.py rename homeassistant/components/recorder/{history.py => history/legacy.py} (96%) delete mode 100644 homeassistant/components/recorder/models.py create mode 100644 homeassistant/components/recorder/models/__init__.py create mode 100644 homeassistant/components/recorder/models/context.py create mode 100644 homeassistant/components/recorder/models/database.py create mode 100644 homeassistant/components/recorder/models/legacy.py create mode 100644 homeassistant/components/recorder/models/state.py create mode 100644 homeassistant/components/recorder/models/state_attributes.py create mode 100644 homeassistant/components/recorder/models/statistics.py create mode 100644 homeassistant/components/recorder/models/time.py diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py new file mode 100644 index 000000000000..1b7b9065b762 --- /dev/null +++ b/homeassistant/components/recorder/history/__init__.py @@ -0,0 +1,22 @@ +"""Provide pre-made queries on top of the recorder component.""" +from __future__ import annotations + +from .const import NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS +from .legacy import ( + get_full_significant_states_with_session, + get_last_state_changes, + get_significant_states, + get_significant_states_with_session, + state_changes_during_period, +) + +# These are the APIs of this package +__all__ = [ + "NEED_ATTRIBUTE_DOMAINS", + "SIGNIFICANT_DOMAINS", + "get_full_significant_states_with_session", + "get_last_state_changes", + "get_significant_states", + "get_significant_states_with_session", + "state_changes_during_period", +] diff --git a/homeassistant/components/recorder/history/common.py b/homeassistant/components/recorder/history/common.py new file mode 100644 index 000000000000..6d0150925d37 --- /dev/null +++ b/homeassistant/components/recorder/history/common.py @@ -0,0 +1,10 @@ +"""Common functions for history.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from ... import recorder + + +def _schema_version(hass: HomeAssistant) -> int: + return recorder.get_instance(hass).schema_version diff --git a/homeassistant/components/recorder/history/const.py b/homeassistant/components/recorder/history/const.py new file mode 100644 index 000000000000..33717ca78cf6 --- /dev/null +++ b/homeassistant/components/recorder/history/const.py @@ -0,0 +1,23 @@ +"""Constants for history.""" + + +STATE_KEY = "state" +LAST_CHANGED_KEY = "last_changed" + +SIGNIFICANT_DOMAINS = { + "climate", + "device_tracker", + "humidifier", + "thermostat", + "water_heater", +} +SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in SIGNIFICANT_DOMAINS] +IGNORE_DOMAINS = {"zone", "scene"} +IGNORE_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in IGNORE_DOMAINS] +NEED_ATTRIBUTE_DOMAINS = { + "climate", + "humidifier", + "input_datetime", + "thermostat", + "water_heater", +} diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history/legacy.py similarity index 96% rename from homeassistant/components/recorder/history.py rename to homeassistant/components/recorder/history/legacy.py index a745716757f4..7d7e3d9b4768 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -22,43 +22,30 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_ from homeassistant.core import HomeAssistant, State, split_entity_id import homeassistant.util.dt as dt_util -from .. import recorder -from .db_schema import RecorderRuns, StateAttributes, States -from .filters import Filters -from .models import ( +from ... import recorder +from ..db_schema import RecorderRuns, StateAttributes, States +from ..filters import Filters +from ..models import ( LazyState, - LazyStatePreSchema31, process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, row_to_compressed_state, - row_to_compressed_state_pre_schema_31, ) -from .util import execute_stmt_lambda_element, session_scope +from ..models.legacy import LazyStatePreSchema31, row_to_compressed_state_pre_schema_31 +from ..util import execute_stmt_lambda_element, session_scope +from .common import _schema_version +from .const import ( + IGNORE_DOMAINS_ENTITY_ID_LIKE, + LAST_CHANGED_KEY, + NEED_ATTRIBUTE_DOMAINS, + SIGNIFICANT_DOMAINS, + SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, + STATE_KEY, +) _LOGGER = logging.getLogger(__name__) -STATE_KEY = "state" -LAST_CHANGED_KEY = "last_changed" - -SIGNIFICANT_DOMAINS = { - "climate", - "device_tracker", - "humidifier", - "thermostat", - "water_heater", -} -SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in SIGNIFICANT_DOMAINS] -IGNORE_DOMAINS = {"zone", "scene"} -IGNORE_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in IGNORE_DOMAINS] -NEED_ATTRIBUTE_DOMAINS = { - "climate", - "humidifier", - "input_datetime", - "thermostat", - "water_heater", -} - _BASE_STATES = ( States.entity_id, @@ -151,11 +138,7 @@ _FIELD_MAP_PRE_SCHEMA_31 = { } -def _schema_version(hass: HomeAssistant) -> int: - return recorder.get_instance(hass).schema_version - - -def lambda_stmt_and_join_attributes( +def _lambda_stmt_and_join_attributes( schema_version: int, no_attributes: bool, include_last_changed: bool = True ) -> tuple[StatementLambdaElement, bool]: """Return the lambda_stmt and if StateAttributes should be joined. @@ -268,7 +251,7 @@ def _significant_states_stmt( no_attributes: bool, ) -> StatementLambdaElement: """Query the database for significant state changes.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=not significant_changes_only ) if ( @@ -442,7 +425,7 @@ def _state_changed_during_period_stmt( descending: bool, limit: int | None, ) -> StatementLambdaElement: - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=False ) if schema_version >= 31: @@ -534,7 +517,7 @@ def state_changes_during_period( def _get_last_state_changes_stmt( schema_version: int, number_of_states: int, entity_id: str ) -> StatementLambdaElement: - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, False, include_last_changed=False ) if schema_version >= 31: @@ -601,7 +584,7 @@ def _get_states_for_entities_stmt( no_attributes: bool, ) -> StatementLambdaElement: """Baked query to get states for specific entities.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We got an include-list of entities, accelerate the query by filtering already @@ -673,7 +656,7 @@ def _get_states_for_all_stmt( no_attributes: bool, ) -> StatementLambdaElement: """Baked query to get states for all entities.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We did not get an include-list of entities, query all states in the inner @@ -787,7 +770,7 @@ def _get_single_entity_states_stmt( ) -> StatementLambdaElement: # Use an entirely different (and extremely fast) query if we only # have a single entity id - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = _lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) if schema_version >= 31: diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py deleted file mode 100644 index 053c870d8a0c..000000000000 --- a/homeassistant/components/recorder/models.py +++ /dev/null @@ -1,521 +0,0 @@ -"""Models for Recorder.""" -from __future__ import annotations - -from contextlib import suppress -from dataclasses import dataclass -from datetime import datetime, timedelta -from functools import lru_cache -import logging -from typing import Any, Literal, TypedDict, overload -from uuid import UUID - -from awesomeversion import AwesomeVersion -from sqlalchemy.engine.row import Row - -from homeassistant.const import ( - COMPRESSED_STATE_ATTRIBUTES, - COMPRESSED_STATE_LAST_CHANGED, - COMPRESSED_STATE_LAST_UPDATED, - COMPRESSED_STATE_STATE, -) -from homeassistant.core import Context, State -import homeassistant.util.dt as dt_util -from homeassistant.util.json import json_loads_object -from homeassistant.util.ulid import bytes_to_ulid, ulid_to_bytes - -from .const import SupportedDialect - -# pylint: disable=invalid-name - -_LOGGER = logging.getLogger(__name__) - -DB_TIMEZONE = "+00:00" - -EMPTY_JSON_OBJECT = "{}" - - -class UnsupportedDialect(Exception): - """The dialect or its version is not supported.""" - - -class StatisticResult(TypedDict): - """Statistic result data class. - - Allows multiple datapoints for the same statistic_id. - """ - - meta: StatisticMetaData - stat: StatisticData - - -class StatisticDataTimestampBase(TypedDict): - """Mandatory fields for statistic data class with a timestamp.""" - - start_ts: float - - -class StatisticDataBase(TypedDict): - """Mandatory fields for statistic data class.""" - - start: datetime - - -class StatisticMixIn(TypedDict, total=False): - """Mandatory fields for statistic data class.""" - - state: float - sum: float - min: float - max: float - mean: float - - -class StatisticData(StatisticDataBase, StatisticMixIn, total=False): - """Statistic data class.""" - - last_reset: datetime | None - - -class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=False): - """Statistic data class with a timestamp.""" - - last_reset_ts: float | None - - -class StatisticMetaData(TypedDict): - """Statistic meta data class.""" - - has_mean: bool - has_sum: bool - name: str | None - source: str - statistic_id: str - unit_of_measurement: str | None - - -@overload -def process_timestamp(ts: None) -> None: - ... - - -@overload -def process_timestamp(ts: datetime) -> datetime: - ... - - -def process_timestamp(ts: datetime | None) -> datetime | None: - """Process a timestamp into datetime object.""" - if ts is None: - return None - if ts.tzinfo is None: - return ts.replace(tzinfo=dt_util.UTC) - - return dt_util.as_utc(ts) - - -@overload -def process_timestamp_to_utc_isoformat(ts: None) -> None: - ... - - -@overload -def process_timestamp_to_utc_isoformat(ts: datetime) -> str: - ... - - -def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: - """Process a timestamp into UTC isotime.""" - if ts is None: - return None - if ts.tzinfo == dt_util.UTC: - return ts.isoformat() - if ts.tzinfo is None: - return f"{ts.isoformat()}{DB_TIMEZONE}" - return ts.astimezone(dt_util.UTC).isoformat() - - -def process_datetime_to_timestamp(ts: datetime) -> float: - """Process a datebase datetime to epoch. - - Mirrors the behavior of process_timestamp_to_utc_isoformat - except it returns the epoch time. - """ - if ts.tzinfo is None or ts.tzinfo == dt_util.UTC: - return dt_util.utc_to_timestamp(ts) - return ts.timestamp() - - -def datetime_to_timestamp_or_none(dt: datetime | None) -> float | None: - """Convert a datetime to a timestamp.""" - if dt is None: - return None - return dt_util.utc_to_timestamp(dt) - - -def timestamp_to_datetime_or_none(ts: float | None) -> datetime | None: - """Convert a timestamp to a datetime.""" - if not ts: - return None - return dt_util.utc_from_timestamp(ts) - - -def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: - """Convert an ulid to bytes.""" - if ulid is None: - return None - return ulid_to_bytes(ulid) - - -def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: - """Convert bytes to a ulid.""" - if _bytes is None: - return None - return bytes_to_ulid(_bytes) - - -@lru_cache(maxsize=16) -def uuid_hex_to_bytes_or_none(uuid_hex: str | None) -> bytes | None: - """Convert a uuid hex to bytes.""" - if uuid_hex is None: - return None - with suppress(ValueError): - return UUID(hex=uuid_hex).bytes - return None - - -@lru_cache(maxsize=16) -def bytes_to_uuid_hex_or_none(_bytes: bytes | None) -> str | None: - """Convert bytes to a uuid hex.""" - if _bytes is None: - return None - with suppress(ValueError): - return UUID(bytes=_bytes).hex - return None - - -class LazyStatePreSchema31(State): - """A lazy version of core State before schema 31.""" - - __slots__ = [ - "_row", - "_attributes", - "_last_changed", - "_last_updated", - "_context", - "attr_cache", - ] - - def __init__( # pylint: disable=super-init-not-called - self, - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - ) -> None: - """Init the lazy state.""" - self._row = row - self.entity_id: str = self._row.entity_id - self.state = self._row.state or "" - self._attributes: dict[str, Any] | None = None - self._last_changed: datetime | None = start_time - self._last_updated: datetime | None = start_time - self._context: Context | None = None - self.attr_cache = attr_cache - - @property # type: ignore[override] - def attributes(self) -> dict[str, Any]: - """State attributes.""" - if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) - return self._attributes - - @attributes.setter - def attributes(self, value: dict[str, Any]) -> None: - """Set attributes.""" - self._attributes = value - - @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: - """Last changed datetime.""" - if self._last_changed is None: - if (last_changed := self._row.last_changed) is not None: - self._last_changed = process_timestamp(last_changed) - else: - self._last_changed = self.last_updated - return self._last_changed - - @last_changed.setter - def last_changed(self, value: datetime) -> None: - """Set last changed datetime.""" - self._last_changed = value - - @property - def last_updated(self) -> datetime: - """Last updated datetime.""" - if self._last_updated is None: - self._last_updated = process_timestamp(self._row.last_updated) - return self._last_updated - - @last_updated.setter - def last_updated(self, value: datetime) -> None: - """Set last updated datetime.""" - self._last_updated = value - - def as_dict(self) -> dict[str, Any]: # type: ignore[override] - """Return a dict representation of the LazyState. - - Async friendly. - - To be used for JSON serialization. - """ - if self._last_changed is None and self._last_updated is None: - last_updated_isoformat = process_timestamp_to_utc_isoformat( - self._row.last_updated - ) - if ( - self._row.last_changed is None - or self._row.last_changed == self._row.last_updated - ): - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = process_timestamp_to_utc_isoformat( - self._row.last_changed - ) - else: - last_updated_isoformat = self.last_updated.isoformat() - if self.last_changed == self.last_updated: - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = self.last_changed.isoformat() - return { - "entity_id": self.entity_id, - "state": self.state, - "attributes": self._attributes or self.attributes, - "last_changed": last_changed_isoformat, - "last_updated": last_updated_isoformat, - } - - -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, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - ) -> None: - """Init the lazy state.""" - self._row = row - self.entity_id: str = self._row.entity_id - self.state = self._row.state or "" - self._attributes: dict[str, Any] | None = None - self._last_updated_ts: float | None = self._row.last_updated_ts or ( - dt_util.utc_to_timestamp(start_time) if start_time else None - ) - self._last_changed_ts: float | None = ( - self._row.last_changed_ts or self._last_updated_ts - ) - self._context: Context | None = None - self.attr_cache = attr_cache - - @property # type: ignore[override] - def attributes(self) -> dict[str, Any]: - """State attributes.""" - if self._attributes is None: - self._attributes = decode_attributes_from_row(self._row, self.attr_cache) - return self._attributes - - @attributes.setter - def attributes(self, value: dict[str, Any]) -> None: - """Set attributes.""" - self._attributes = value - - @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: - """Last changed datetime.""" - assert self._last_changed_ts is not None - return dt_util.utc_from_timestamp(self._last_changed_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: - """Last updated datetime.""" - 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. - - Async friendly. - - To be used for JSON serialization. - """ - last_updated_isoformat = self.last_updated.isoformat() - if self._last_changed_ts == self._last_updated_ts: - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = self.last_changed.isoformat() - return { - "entity_id": self.entity_id, - "state": self.state, - "attributes": self._attributes or self.attributes, - "last_changed": last_changed_isoformat, - "last_updated": last_updated_isoformat, - } - - -def decode_attributes_from_row( - row: Row, attr_cache: dict[str, dict[str, Any]] -) -> dict[str, Any]: - """Decode attributes from a database row.""" - source: str = row.shared_attrs or row.attributes - if (attributes := attr_cache.get(source)) is not None: - return attributes - if not source or source == EMPTY_JSON_OBJECT: - return {} - try: - attr_cache[source] = attributes = json_loads_object(source) - except ValueError: - _LOGGER.exception("Error converting row to state attributes: %s", source) - attr_cache[source] = attributes = {} - return attributes - - -def row_to_compressed_state( - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, -) -> dict[str, Any]: - """Convert a database row to a compressed state schema 31 and later.""" - comp_state = { - COMPRESSED_STATE_STATE: row.state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), - } - if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) - else: - row_last_updated_ts: float = row.last_updated_ts - comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts - if ( - row_changed_changed_ts := row.last_changed_ts - ) and row_last_updated_ts != row_changed_changed_ts: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_changed_changed_ts - return comp_state - - -def row_to_compressed_state_pre_schema_31( - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, -) -> dict[str, Any]: - """Convert a database row to a compressed state before schema 31.""" - comp_state = { - COMPRESSED_STATE_STATE: row.state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), - } - if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() - else: - row_last_updated: datetime = row.last_updated - comp_state[COMPRESSED_STATE_LAST_UPDATED] = process_datetime_to_timestamp( - row_last_updated - ) - if ( - row_changed_changed := row.last_changed - ) and row_last_updated != row_changed_changed: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = process_datetime_to_timestamp( - row_changed_changed - ) - return comp_state - - -class CalendarStatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - period: Literal["hour", "day", "week", "month", "year"] - offset: int - - -class FixedStatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - end_time: datetime - start_time: datetime - - -class RollingWindowStatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - duration: timedelta - offset: timedelta - - -class StatisticPeriod(TypedDict, total=False): - """Statistic period definition.""" - - calendar: CalendarStatisticPeriod - fixed_period: FixedStatisticPeriod - rolling_window: RollingWindowStatisticPeriod - - -@dataclass -class DatabaseEngine: - """Properties of the database engine.""" - - dialect: SupportedDialect - optimizer: DatabaseOptimizer - version: AwesomeVersion | None - - -@dataclass -class DatabaseOptimizer: - """Properties of the database optimizer for the configured database engine.""" - - # Some MariaDB versions have a bug that causes a slow query when using - # a range in a select statement with an IN clause. - # - # https://jira.mariadb.org/browse/MDEV-25020 - # - slow_range_in_select: bool diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py new file mode 100644 index 000000000000..3aec02b8d4b7 --- /dev/null +++ b/homeassistant/components/recorder/models/__init__.py @@ -0,0 +1,53 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from .context import ( + bytes_to_ulid_or_none, + bytes_to_uuid_hex_or_none, + ulid_to_bytes_or_none, + uuid_hex_to_bytes_or_none, +) +from .database import DatabaseEngine, DatabaseOptimizer, UnsupportedDialect +from .state import LazyState, row_to_compressed_state +from .statistics import ( + CalendarStatisticPeriod, + FixedStatisticPeriod, + RollingWindowStatisticPeriod, + StatisticData, + StatisticDataTimestamp, + StatisticMetaData, + StatisticPeriod, + StatisticResult, +) +from .time import ( + datetime_to_timestamp_or_none, + process_datetime_to_timestamp, + process_timestamp, + process_timestamp_to_utc_isoformat, + timestamp_to_datetime_or_none, +) + +__all__ = [ + "CalendarStatisticPeriod", + "DatabaseEngine", + "DatabaseOptimizer", + "FixedStatisticPeriod", + "LazyState", + "RollingWindowStatisticPeriod", + "StatisticData", + "StatisticDataTimestamp", + "StatisticMetaData", + "StatisticPeriod", + "StatisticResult", + "UnsupportedDialect", + "bytes_to_ulid_or_none", + "bytes_to_uuid_hex_or_none", + "datetime_to_timestamp_or_none", + "process_datetime_to_timestamp", + "process_timestamp", + "process_timestamp_to_utc_isoformat", + "row_to_compressed_state", + "timestamp_to_datetime_or_none", + "ulid_to_bytes_or_none", + "uuid_hex_to_bytes_or_none", +] diff --git a/homeassistant/components/recorder/models/context.py b/homeassistant/components/recorder/models/context.py new file mode 100644 index 000000000000..dbd9383bdeba --- /dev/null +++ b/homeassistant/components/recorder/models/context.py @@ -0,0 +1,42 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from contextlib import suppress +from functools import lru_cache +from uuid import UUID + +from homeassistant.util.ulid import bytes_to_ulid, ulid_to_bytes + + +def ulid_to_bytes_or_none(ulid: str | None) -> bytes | None: + """Convert an ulid to bytes.""" + if ulid is None: + return None + return ulid_to_bytes(ulid) + + +def bytes_to_ulid_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a ulid.""" + if _bytes is None: + return None + return bytes_to_ulid(_bytes) + + +@lru_cache(maxsize=16) +def uuid_hex_to_bytes_or_none(uuid_hex: str | None) -> bytes | None: + """Convert a uuid hex to bytes.""" + if uuid_hex is None: + return None + with suppress(ValueError): + return UUID(hex=uuid_hex).bytes + return None + + +@lru_cache(maxsize=16) +def bytes_to_uuid_hex_or_none(_bytes: bytes | None) -> str | None: + """Convert bytes to a uuid hex.""" + if _bytes is None: + return None + with suppress(ValueError): + return UUID(bytes=_bytes).hex + return None diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py new file mode 100644 index 000000000000..e39f05cd9c5a --- /dev/null +++ b/homeassistant/components/recorder/models/database.py @@ -0,0 +1,33 @@ +"""Models for the database in the Recorder.""" +from __future__ import annotations + +from dataclasses import dataclass + +from awesomeversion import AwesomeVersion + +from ..const import SupportedDialect + + +class UnsupportedDialect(Exception): + """The dialect or its version is not supported.""" + + +@dataclass +class DatabaseEngine: + """Properties of the database engine.""" + + dialect: SupportedDialect + optimizer: DatabaseOptimizer + version: AwesomeVersion | None + + +@dataclass +class DatabaseOptimizer: + """Properties of the database optimizer for the configured database engine.""" + + # Some MariaDB versions have a bug that causes a slow query when using + # a range in a select statement with an IN clause. + # + # https://jira.mariadb.org/browse/MDEV-25020 + # + slow_range_in_select: bool diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py new file mode 100644 index 000000000000..c26e51777203 --- /dev/null +++ b/homeassistant/components/recorder/models/legacy.py @@ -0,0 +1,164 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy.engine.row import Row + +from homeassistant.const import ( + COMPRESSED_STATE_ATTRIBUTES, + COMPRESSED_STATE_LAST_CHANGED, + COMPRESSED_STATE_LAST_UPDATED, + COMPRESSED_STATE_STATE, +) +from homeassistant.core import Context, State + +from .state_attributes import decode_attributes_from_row +from .time import ( + process_datetime_to_timestamp, + process_timestamp, + process_timestamp_to_utc_isoformat, +) + +# pylint: disable=invalid-name + + +class LazyStatePreSchema31(State): + """A lazy version of core State before schema 31.""" + + __slots__ = [ + "_row", + "_attributes", + "_last_changed", + "_last_updated", + "_context", + "attr_cache", + ] + + def __init__( # pylint: disable=super-init-not-called + self, + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, + ) -> None: + """Init the lazy state.""" + self._row = row + self.entity_id: str = self._row.entity_id + self.state = self._row.state or "" + self._attributes: dict[str, Any] | None = None + self._last_changed: datetime | None = start_time + self._last_updated: datetime | None = start_time + self._context: Context | None = None + self.attr_cache = attr_cache + + @property # type: ignore[override] + def attributes(self) -> dict[str, Any]: + """State attributes.""" + if self._attributes is None: + self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + return self._attributes + + @attributes.setter + def attributes(self, value: dict[str, Any]) -> None: + """Set attributes.""" + self._attributes = value + + @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: + """Last changed datetime.""" + if self._last_changed is None: + if (last_changed := self._row.last_changed) is not None: + self._last_changed = process_timestamp(last_changed) + else: + self._last_changed = self.last_updated + return self._last_changed + + @last_changed.setter + def last_changed(self, value: datetime) -> None: + """Set last changed datetime.""" + self._last_changed = value + + @property + def last_updated(self) -> datetime: + """Last updated datetime.""" + if self._last_updated is None: + self._last_updated = process_timestamp(self._row.last_updated) + return self._last_updated + + @last_updated.setter + def last_updated(self, value: datetime) -> None: + """Set last updated datetime.""" + self._last_updated = value + + def as_dict(self) -> dict[str, Any]: # type: ignore[override] + """Return a dict representation of the LazyState. + + Async friendly. + + To be used for JSON serialization. + """ + if self._last_changed is None and self._last_updated is None: + last_updated_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_updated + ) + if ( + self._row.last_changed is None + or self._row.last_changed == self._row.last_updated + ): + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = process_timestamp_to_utc_isoformat( + self._row.last_changed + ) + else: + last_updated_isoformat = self.last_updated.isoformat() + if self.last_changed == self.last_updated: + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = self.last_changed.isoformat() + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + +def row_to_compressed_state_pre_schema_31( + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, +) -> dict[str, Any]: + """Convert a database row to a compressed state before schema 31.""" + comp_state = { + COMPRESSED_STATE_STATE: row.state, + COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), + } + if start_time: + comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() + else: + row_last_updated: datetime = row.last_updated + comp_state[COMPRESSED_STATE_LAST_UPDATED] = process_datetime_to_timestamp( + row_last_updated + ) + if ( + row_changed_changed := row.last_changed + ) and row_last_updated != row_changed_changed: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = process_datetime_to_timestamp( + row_changed_changed + ) + return comp_state diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py new file mode 100644 index 000000000000..12983a3e6886 --- /dev/null +++ b/homeassistant/components/recorder/models/state.py @@ -0,0 +1,145 @@ +"""Models states in for Recorder.""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any + +from sqlalchemy.engine.row import Row + +from homeassistant.const import ( + COMPRESSED_STATE_ATTRIBUTES, + COMPRESSED_STATE_LAST_CHANGED, + COMPRESSED_STATE_LAST_UPDATED, + COMPRESSED_STATE_STATE, +) +from homeassistant.core import Context, State +import homeassistant.util.dt as dt_util + +from .state_attributes import decode_attributes_from_row +from .time import process_timestamp + +# pylint: disable=invalid-name + +_LOGGER = logging.getLogger(__name__) + + +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, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, + ) -> None: + """Init the lazy state.""" + self._row = row + self.entity_id: str = self._row.entity_id + self.state = self._row.state or "" + self._attributes: dict[str, Any] | None = None + self._last_updated_ts: float | None = self._row.last_updated_ts or ( + dt_util.utc_to_timestamp(start_time) if start_time else None + ) + self._last_changed_ts: float | None = ( + self._row.last_changed_ts or self._last_updated_ts + ) + self._context: Context | None = None + self.attr_cache = attr_cache + + @property # type: ignore[override] + def attributes(self) -> dict[str, Any]: + """State attributes.""" + if self._attributes is None: + self._attributes = decode_attributes_from_row(self._row, self.attr_cache) + return self._attributes + + @attributes.setter + def attributes(self, value: dict[str, Any]) -> None: + """Set attributes.""" + self._attributes = value + + @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: + """Last changed datetime.""" + assert self._last_changed_ts is not None + return dt_util.utc_from_timestamp(self._last_changed_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: + """Last updated datetime.""" + 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. + + Async friendly. + + To be used for JSON serialization. + """ + last_updated_isoformat = self.last_updated.isoformat() + if self._last_changed_ts == self._last_updated_ts: + last_changed_isoformat = last_updated_isoformat + else: + last_changed_isoformat = self.last_changed.isoformat() + return { + "entity_id": self.entity_id, + "state": self.state, + "attributes": self._attributes or self.attributes, + "last_changed": last_changed_isoformat, + "last_updated": last_updated_isoformat, + } + + +def row_to_compressed_state( + row: Row, + attr_cache: dict[str, dict[str, Any]], + start_time: datetime | None, +) -> dict[str, Any]: + """Convert a database row to a compressed state schema 31 and later.""" + comp_state = { + COMPRESSED_STATE_STATE: row.state, + COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row(row, attr_cache), + } + if start_time: + comp_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp(start_time) + else: + row_last_updated_ts: float = row.last_updated_ts + comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts + if ( + row_changed_changed_ts := row.last_changed_ts + ) and row_last_updated_ts != row_changed_changed_ts: + comp_state[COMPRESSED_STATE_LAST_CHANGED] = row_changed_changed_ts + return comp_state diff --git a/homeassistant/components/recorder/models/state_attributes.py b/homeassistant/components/recorder/models/state_attributes.py new file mode 100644 index 000000000000..738684c02153 --- /dev/null +++ b/homeassistant/components/recorder/models/state_attributes.py @@ -0,0 +1,30 @@ +"""State attributes models.""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy.engine.row import Row + +from homeassistant.util.json import json_loads_object + +EMPTY_JSON_OBJECT = "{}" +_LOGGER = logging.getLogger(__name__) + + +def decode_attributes_from_row( + row: Row, attr_cache: dict[str, dict[str, Any]] +) -> dict[str, Any]: + """Decode attributes from a database row.""" + source: str = row.shared_attrs or row.attributes + if (attributes := attr_cache.get(source)) is not None: + return attributes + if not source or source == EMPTY_JSON_OBJECT: + return {} + try: + attr_cache[source] = attributes = json_loads_object(source) + except ValueError: + _LOGGER.exception("Error converting row to state attributes: %s", source) + attr_cache[source] = attributes = {} + return attributes diff --git a/homeassistant/components/recorder/models/statistics.py b/homeassistant/components/recorder/models/statistics.py new file mode 100644 index 000000000000..4cf465955c51 --- /dev/null +++ b/homeassistant/components/recorder/models/statistics.py @@ -0,0 +1,89 @@ +"""Models for statistics in the Recorder.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Literal, TypedDict + + +class StatisticResult(TypedDict): + """Statistic result data class. + + Allows multiple datapoints for the same statistic_id. + """ + + meta: StatisticMetaData + stat: StatisticData + + +class StatisticDataTimestampBase(TypedDict): + """Mandatory fields for statistic data class with a timestamp.""" + + start_ts: float + + +class StatisticDataBase(TypedDict): + """Mandatory fields for statistic data class.""" + + start: datetime + + +class StatisticMixIn(TypedDict, total=False): + """Mandatory fields for statistic data class.""" + + state: float + sum: float + min: float + max: float + mean: float + + +class StatisticData(StatisticDataBase, StatisticMixIn, total=False): + """Statistic data class.""" + + last_reset: datetime | None + + +class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=False): + """Statistic data class with a timestamp.""" + + last_reset_ts: float | None + + +class StatisticMetaData(TypedDict): + """Statistic meta data class.""" + + has_mean: bool + has_sum: bool + name: str | None + source: str + statistic_id: str + unit_of_measurement: str | None + + +class CalendarStatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + period: Literal["hour", "day", "week", "month", "year"] + offset: int + + +class FixedStatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + end_time: datetime + start_time: datetime + + +class RollingWindowStatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + duration: timedelta + offset: timedelta + + +class StatisticPeriod(TypedDict, total=False): + """Statistic period definition.""" + + calendar: CalendarStatisticPeriod + fixed_period: FixedStatisticPeriod + rolling_window: RollingWindowStatisticPeriod diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py new file mode 100644 index 000000000000..078a982d5ad9 --- /dev/null +++ b/homeassistant/components/recorder/models/time.py @@ -0,0 +1,82 @@ +"""Models for Recorder.""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import overload + +import homeassistant.util.dt as dt_util + +# pylint: disable=invalid-name + +_LOGGER = logging.getLogger(__name__) + +DB_TIMEZONE = "+00:00" + +EMPTY_JSON_OBJECT = "{}" + + +@overload +def process_timestamp(ts: None) -> None: + ... + + +@overload +def process_timestamp(ts: datetime) -> datetime: + ... + + +def process_timestamp(ts: datetime | None) -> datetime | None: + """Process a timestamp into datetime object.""" + if ts is None: + return None + if ts.tzinfo is None: + return ts.replace(tzinfo=dt_util.UTC) + + return dt_util.as_utc(ts) + + +@overload +def process_timestamp_to_utc_isoformat(ts: None) -> None: + ... + + +@overload +def process_timestamp_to_utc_isoformat(ts: datetime) -> str: + ... + + +def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: + """Process a timestamp into UTC isotime.""" + if ts is None: + return None + if ts.tzinfo == dt_util.UTC: + return ts.isoformat() + if ts.tzinfo is None: + return f"{ts.isoformat()}{DB_TIMEZONE}" + return ts.astimezone(dt_util.UTC).isoformat() + + +def process_datetime_to_timestamp(ts: datetime) -> float: + """Process a datebase datetime to epoch. + + Mirrors the behavior of process_timestamp_to_utc_isoformat + except it returns the epoch time. + """ + if ts.tzinfo is None or ts.tzinfo == dt_util.UTC: + return dt_util.utc_to_timestamp(ts) + return ts.timestamp() + + +def datetime_to_timestamp_or_none(dt: datetime | None) -> float | None: + """Convert a datetime to a timestamp.""" + if dt is None: + return None + return dt_util.utc_to_timestamp(dt) + + +def timestamp_to_datetime_or_none(ts: float | None) -> datetime | None: + """Convert a timestamp to a datetime.""" + if not ts: + return None + return dt_util.utc_from_timestamp(ts) diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 2b4bed072a41..ccde8c5d1877 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -20,11 +20,9 @@ from homeassistant.components.recorder.db_schema import ( StateAttributes, States, ) -from homeassistant.components.recorder.models import ( - LazyState, - LazyStatePreSchema31, - process_timestamp, -) +from homeassistant.components.recorder.history import legacy +from homeassistant.components.recorder.models import LazyState, process_timestamp +from homeassistant.components.recorder.models.legacy import LazyStatePreSchema31 from homeassistant.components.recorder.util import session_scope import homeassistant.core as ha from homeassistant.core import HomeAssistant, State @@ -63,7 +61,7 @@ async def _async_get_states( attr_cache = {} return [ klass(row, attr_cache, None) - for row in history._get_rows_with_session( + for row in legacy._get_rows_with_session( hass, session, utc_point_in_time, diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 609af6c362fa..78302f74278f 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -15,9 +15,12 @@ from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components import recorder -from homeassistant.components.recorder import history, util +from homeassistant.components.recorder import util from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX from homeassistant.components.recorder.db_schema import RecorderRuns +from homeassistant.components.recorder.history.legacy import ( + _get_single_entity_states_stmt, +) from homeassistant.components.recorder.models import ( UnsupportedDialect, process_timestamp, @@ -905,7 +908,7 @@ def test_execute_stmt_lambda_element( with session_scope(hass=hass) as session: # No time window, we always get a list - stmt = history._get_single_entity_states_stmt( + stmt = _get_single_entity_states_stmt( instance.schema_version, dt_util.utcnow(), "sensor.on", False ) rows = util.execute_stmt_lambda_element(session, stmt) From 1c57339ec3069b4a5ab7581add62eb6e3320263d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 16:51:16 -1000 Subject: [PATCH 0398/1058] Refactor recorder tests to use recorder history API (#89565) --- tests/components/automation/test_recorder.py | 39 ++++++---------- tests/components/calendar/test_recorder.py | 28 ++++------- tests/components/camera/test_recorder.py | 34 +++++--------- tests/components/climate/test_recorder.py | 44 +++++++----------- tests/components/fan/test_recorder.py | 28 ++++------- tests/components/group/test_recorder.py | 37 +++++---------- tests/components/humidifier/test_recorder.py | 32 ++++--------- .../components/input_boolean/test_recorder.py | 28 ++++------- .../components/input_button/test_recorder.py | 27 ++++------- .../input_datetime/test_recorder.py | 31 ++++--------- .../components/input_number/test_recorder.py | 35 +++++--------- .../components/input_select/test_recorder.py | 29 ++++-------- tests/components/input_text/test_recorder.py | 35 +++++--------- tests/components/light/test_recorder.py | 40 ++++++---------- .../components/media_player/test_recorder.py | 40 ++++++---------- tests/components/number/test_recorder.py | 36 +++++---------- tests/components/schedule/test_recorder.py | 33 +++++-------- tests/components/script/test_recorder.py | 39 ++++++---------- tests/components/select/test_recorder.py | 30 ++++-------- tests/components/siren/test_recorder.py | 30 ++++-------- tests/components/sun/test_recorder.py | 46 +++++++------------ tests/components/text/test_recorder.py | 32 ++++--------- .../components/unifiprotect/test_recorder.py | 33 ++++--------- tests/components/update/test_recorder.py | 34 +++++--------- tests/components/vacuum/test_recorder.py | 30 ++++-------- .../components/water_heater/test_recorder.py | 34 +++++--------- tests/components/weather/test_recorder.py | 28 ++++------- 27 files changed, 296 insertions(+), 616 deletions(-) diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index 7e132759a920..d4fde85f501c 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -12,11 +12,11 @@ from homeassistant.components.automation import ( CONF_ID, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +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_mock_service from tests.components.recorder.common import async_wait_recording_done @@ -32,6 +32,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, calls ) -> None: """Test automation registered attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, automation.DOMAIN, @@ -49,25 +50,13 @@ async def test_exclude_attributes( assert ["hello.world"] == calls[0].data.get(ATTR_ENTITY_ID) await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_LAST_TRIGGERED not in state.attributes - assert ATTR_MODE not in state.attributes - assert ATTR_CUR not in state.attributes - assert CONF_ID not in state.attributes - assert ATTR_MAX not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) == 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_LAST_TRIGGERED not in state.attributes + assert ATTR_MODE not in state.attributes + assert ATTR_CUR not in state.attributes + assert CONF_ID not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/calendar/test_recorder.py b/tests/components/calendar/test_recorder.py index 38f84436d60a..9b8897776110 100644 --- a/tests/components/calendar/test_recorder.py +++ b/tests/components/calendar/test_recorder.py @@ -2,10 +2,9 @@ from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -15,6 +14,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test sensor attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) await hass.async_block_till_done() @@ -28,21 +28,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_FRIENDLY_NAME in state.attributes - assert "description" not in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_FRIENDLY_NAME in state.attributes + assert "description" not in state.attributes diff --git a/tests/components/camera/test_recorder.py b/tests/components/camera/test_recorder.py index d5f72fe1c913..9230756cec08 100644 --- a/tests/components/camera/test_recorder.py +++ b/tests/components/camera/test_recorder.py @@ -5,15 +5,14 @@ from datetime import timedelta from homeassistant.components import camera from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -23,6 +22,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test camera registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} ) @@ -31,24 +31,12 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_camera_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_camera_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert "access_token" not in state.attributes - assert ATTR_ENTITY_PICTURE not in state.attributes - assert ATTR_ATTRIBUTION not in state.attributes - assert ATTR_SUPPORTED_FEATURES not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert "access_token" not in state.attributes + assert ATTR_ENTITY_PICTURE not in state.attributes + assert ATTR_ATTRIBUTION not in state.attributes + assert ATTR_SUPPORTED_FEATURES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/climate/test_recorder.py b/tests/components/climate/test_recorder.py index a2b3ac05a961..df9b64631b38 100644 --- a/tests/components/climate/test_recorder.py +++ b/tests/components/climate/test_recorder.py @@ -16,10 +16,9 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_STEP, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -29,6 +28,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test climate registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, climate.DOMAIN, {climate.DOMAIN: {"platform": "demo"}} ) @@ -37,29 +37,17 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_PRESET_MODES not in state.attributes - assert ATTR_HVAC_MODES not in state.attributes - assert ATTR_FAN_MODES not in state.attributes - assert ATTR_SWING_MODES not in state.attributes - assert ATTR_MIN_TEMP not in state.attributes - assert ATTR_MAX_TEMP not in state.attributes - assert ATTR_MIN_HUMIDITY not in state.attributes - assert ATTR_MAX_HUMIDITY not in state.attributes - assert ATTR_TARGET_TEMP_STEP not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_PRESET_MODES not in state.attributes + assert ATTR_HVAC_MODES not in state.attributes + assert ATTR_FAN_MODES not in state.attributes + assert ATTR_SWING_MODES not in state.attributes + assert ATTR_MIN_TEMP not in state.attributes + assert ATTR_MAX_TEMP not in state.attributes + assert ATTR_MIN_HUMIDITY not in state.attributes + assert ATTR_MAX_HUMIDITY not in state.attributes + assert ATTR_TARGET_TEMP_STEP not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/fan/test_recorder.py b/tests/components/fan/test_recorder.py index fe2cf27c98ab..8c42e20b7396 100644 --- a/tests/components/fan/test_recorder.py +++ b/tests/components/fan/test_recorder.py @@ -6,10 +6,9 @@ from datetime import timedelta from homeassistant.components import fan from homeassistant.components.fan import ATTR_PRESET_MODES from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,27 +18,16 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test fan registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, fan.DOMAIN, {fan.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_PRESET_MODES not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_PRESET_MODES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/group/test_recorder.py b/tests/components/group/test_recorder.py index 2831c82b31ec..2c100a2e3cb7 100644 --- a/tests/components/group/test_recorder.py +++ b/tests/components/group/test_recorder.py @@ -6,10 +6,9 @@ from datetime import timedelta from homeassistant.components import group from homeassistant.components.group import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_ON -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test number registered attributes to be excluded.""" + now = dt_util.utcnow() hass.states.async_set("light.bowl", STATE_ON) assert await async_setup_component(hass, "light", {}) @@ -38,27 +38,12 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - attr_ids = {} - for db_state_attributes in session.query(StateAttributes): - attr_ids[ - db_state_attributes.attributes_id - ] = db_state_attributes.to_native() - for db_state, _ in session.query(States, StateAttributes).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = attr_ids[db_state.attributes_id] - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - if state.domain == group.DOMAIN: - assert ATTR_AUTO not in state.attributes - assert ATTR_ENTITY_ID not in state.attributes - assert ATTR_ORDER not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + if split_entity_id(state.entity_id)[0] == group.DOMAIN: + assert ATTR_AUTO not in state.attributes + assert ATTR_ENTITY_ID not in state.attributes + assert ATTR_ORDER not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/humidifier/test_recorder.py b/tests/components/humidifier/test_recorder.py index e0f30b65c63b..0b4947847fa8 100644 --- a/tests/components/humidifier/test_recorder.py +++ b/tests/components/humidifier/test_recorder.py @@ -10,10 +10,9 @@ from homeassistant.components.humidifier import ( ATTR_MIN_HUMIDITY, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -23,6 +22,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test humidifier registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, humidifier.DOMAIN, {humidifier.DOMAIN: {"platform": "demo"}} ) @@ -31,23 +31,11 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_MIN_HUMIDITY not in state.attributes - assert ATTR_MAX_HUMIDITY not in state.attributes - assert ATTR_AVAILABLE_MODES not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_MIN_HUMIDITY not in state.attributes + assert ATTR_MAX_HUMIDITY not in state.attributes + assert ATTR_AVAILABLE_MODES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/input_boolean/test_recorder.py b/tests/components/input_boolean/test_recorder.py index a4cc3b998da6..c2e759ec72a1 100644 --- a/tests/components/input_boolean/test_recorder.py +++ b/tests/components/input_boolean/test_recorder.py @@ -5,10 +5,9 @@ from datetime import timedelta from homeassistant.components.input_boolean import DOMAIN from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -20,6 +19,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) state = hass.states.get("input_boolean.test") @@ -30,20 +30,8 @@ async def test_exclude_attributes( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes diff --git a/tests/components/input_button/test_recorder.py b/tests/components/input_button/test_recorder.py index 04898459987c..0887756ae183 100644 --- a/tests/components/input_button/test_recorder.py +++ b/tests/components/input_button/test_recorder.py @@ -5,10 +5,9 @@ from datetime import timedelta from homeassistant.components.input_button import DOMAIN from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -20,6 +19,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) state = hass.states.get("input_button.test") @@ -31,19 +31,8 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes diff --git a/tests/components/input_datetime/test_recorder.py b/tests/components/input_datetime/test_recorder.py index a59004ee06c5..59abefdd7d87 100644 --- a/tests/components/input_datetime/test_recorder.py +++ b/tests/components/input_datetime/test_recorder.py @@ -5,10 +5,9 @@ from datetime import timedelta from homeassistant.components.input_datetime import CONF_HAS_DATE, CONF_HAS_TIME, DOMAIN from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -20,6 +19,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, DOMAIN, {DOMAIN: {"test": {CONF_HAS_TIME: True}}} ) @@ -35,21 +35,10 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert CONF_HAS_DATE not in states[0].attributes - assert CONF_HAS_TIME not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert CONF_HAS_DATE not in state.attributes + assert CONF_HAS_TIME not in state.attributes diff --git a/tests/components/input_number/test_recorder.py b/tests/components/input_number/test_recorder.py index 982e8942c461..1e489ec40c3b 100644 --- a/tests/components/input_number/test_recorder.py +++ b/tests/components/input_number/test_recorder.py @@ -11,10 +11,9 @@ from homeassistant.components.input_number import ( DOMAIN, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -26,6 +25,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, DOMAIN, {DOMAIN: {"test": {"min": 0, "max": 100}}} ) @@ -43,23 +43,12 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert ATTR_MIN not in states[0].attributes - assert ATTR_MAX not in states[0].attributes - assert ATTR_STEP not in states[0].attributes - assert ATTR_MODE not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert ATTR_MIN not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_STEP not in state.attributes + assert ATTR_MODE not in state.attributes diff --git a/tests/components/input_select/test_recorder.py b/tests/components/input_select/test_recorder.py index 5013228e6f41..084009b163a4 100644 --- a/tests/components/input_select/test_recorder.py +++ b/tests/components/input_select/test_recorder.py @@ -5,10 +5,9 @@ from datetime import timedelta from homeassistant.components.input_select import ATTR_OPTIONS, DOMAIN from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -20,6 +19,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, DOMAIN, @@ -42,20 +42,9 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert ATTR_OPTIONS in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert ATTR_OPTIONS in state.attributes diff --git a/tests/components/input_text/test_recorder.py b/tests/components/input_text/test_recorder.py index 4867d453072a..bc2e7d9f5af5 100644 --- a/tests/components/input_text/test_recorder.py +++ b/tests/components/input_text/test_recorder.py @@ -12,10 +12,9 @@ from homeassistant.components.input_text import ( MODE_TEXT, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -27,6 +26,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {"test": {}}}) state = hass.states.get("input_text.test") @@ -42,23 +42,12 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert ATTR_MAX not in states[0].attributes - assert ATTR_MIN not in states[0].attributes - assert ATTR_MODE not in states[0].attributes - assert ATTR_PATTERN not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_MIN not in state.attributes + assert ATTR_MODE not in state.attributes + assert ATTR_PATTERN not in state.attributes diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index b4e0621d23de..fd95964e5551 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -13,10 +13,9 @@ from homeassistant.components.light import ( ATTR_SUPPORTED_COLOR_MODES, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -26,6 +25,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test light registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, light.DOMAIN, {light.DOMAIN: {"platform": "demo"}} ) @@ -34,26 +34,14 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_MIN_MIREDS not in state.attributes - assert ATTR_MAX_MIREDS not in state.attributes - assert ATTR_SUPPORTED_COLOR_MODES not in state.attributes - assert ATTR_EFFECT not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes - assert ATTR_MAX_COLOR_TEMP_KELVIN not in state.attributes - assert ATTR_MIN_COLOR_TEMP_KELVIN not in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_MIN_MIREDS not in state.attributes + assert ATTR_MAX_MIREDS not in state.attributes + assert ATTR_SUPPORTED_COLOR_MODES not in state.attributes + assert ATTR_EFFECT not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes + assert ATTR_MAX_COLOR_TEMP_KELVIN not in state.attributes + assert ATTR_MIN_COLOR_TEMP_KELVIN not in state.attributes diff --git a/tests/components/media_player/test_recorder.py b/tests/components/media_player/test_recorder.py index 90e74bf54b68..ba6b99b2e3e7 100644 --- a/tests/components/media_player/test_recorder.py +++ b/tests/components/media_player/test_recorder.py @@ -12,10 +12,9 @@ from homeassistant.components.media_player import ( ATTR_SOUND_MODE_LIST, ) from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -25,6 +24,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test media_player registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, media_player.DOMAIN, {media_player.DOMAIN: {"platform": "demo"}} ) @@ -33,26 +33,14 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_ENTITY_PICTURE not in state.attributes - assert ATTR_ENTITY_PICTURE_LOCAL not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes - assert ATTR_INPUT_SOURCE_LIST not in state.attributes - assert ATTR_MEDIA_POSITION not in state.attributes - assert ATTR_MEDIA_POSITION_UPDATED_AT not in state.attributes - assert ATTR_SOUND_MODE_LIST not in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_ENTITY_PICTURE not in state.attributes + assert ATTR_ENTITY_PICTURE_LOCAL not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes + assert ATTR_INPUT_SOURCE_LIST not in state.attributes + assert ATTR_MEDIA_POSITION not in state.attributes + assert ATTR_MEDIA_POSITION_UPDATED_AT not in state.attributes + assert ATTR_SOUND_MODE_LIST not in state.attributes diff --git a/tests/components/number/test_recorder.py b/tests/components/number/test_recorder.py index 447f4bd76e64..1d1f7c506e6f 100644 --- a/tests/components/number/test_recorder.py +++ b/tests/components/number/test_recorder.py @@ -6,10 +6,9 @@ from datetime import timedelta from homeassistant.components import number from homeassistant.components.number import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -23,28 +22,17 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) hass, number.DOMAIN, {number.DOMAIN: {"platform": "demo"}} ) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + now = dt_util.utcnow() + async_fire_time_changed(hass, now + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) + states = await hass.async_add_executor_job(get_significant_states, hass, now) assert len(states) > 1 - for state in states: - assert ATTR_MIN not in state.attributes - assert ATTR_MAX not in state.attributes - assert ATTR_STEP not in state.attributes - assert ATTR_MODE not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + for entity_states in states.values(): + for state in entity_states: + assert ATTR_MIN not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_STEP not in state.attributes + assert ATTR_MODE not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/schedule/test_recorder.py b/tests/components/schedule/test_recorder.py index 75040bc1a092..ee1660653d93 100644 --- a/tests/components/schedule/test_recorder.py +++ b/tests/components/schedule/test_recorder.py @@ -4,11 +4,10 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.schedule.const import ATTR_NEXT_EVENT, DOMAIN from homeassistant.const import ATTR_EDITABLE, ATTR_FRIENDLY_NAME, ATTR_ICON -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -22,6 +21,7 @@ async def test_exclude_attributes( enable_custom_integrations: None, ) -> None: """Test attributes to be excluded.""" + now = dt_util.utcnow() assert await async_setup_component( hass, DOMAIN, @@ -54,22 +54,11 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) == 1 - assert ATTR_EDITABLE not in states[0].attributes - assert ATTR_FRIENDLY_NAME in states[0].attributes - assert ATTR_ICON in states[0].attributes - assert ATTR_NEXT_EVENT not in states[0].attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EDITABLE not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes + assert ATTR_ICON in state.attributes + assert ATTR_NEXT_EVENT not in state.attributes diff --git a/tests/components/script/test_recorder.py b/tests/components/script/test_recorder.py index ecda80df1d95..7204fce3f443 100644 --- a/tests/components/script/test_recorder.py +++ b/tests/components/script/test_recorder.py @@ -5,8 +5,7 @@ import pytest from homeassistant.components import script from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.script import ( ATTR_CUR, ATTR_LAST_ACTION, @@ -15,8 +14,9 @@ from homeassistant.components.script import ( ATTR_MODE, ) from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import async_mock_service from tests.components.recorder.common import async_wait_recording_done @@ -32,6 +32,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, calls ) -> None: """Test automation registered attributes to be excluded.""" + now = dt_util.utcnow() await hass.async_block_till_done() calls = [] context = Context() @@ -65,25 +66,13 @@ async def test_exclude_attributes( await async_wait_recording_done(hass) assert len(calls) == 1 - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_LAST_TRIGGERED not in state.attributes - assert ATTR_MODE not in state.attributes - assert ATTR_CUR not in state.attributes - assert ATTR_LAST_ACTION not in state.attributes - assert ATTR_MAX not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_LAST_TRIGGERED not in state.attributes + assert ATTR_MODE not in state.attributes + assert ATTR_CUR not in state.attributes + assert ATTR_LAST_ACTION not in state.attributes + assert ATTR_MAX not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/select/test_recorder.py b/tests/components/select/test_recorder.py index 733949d50cff..075a6e2486a2 100644 --- a/tests/components/select/test_recorder.py +++ b/tests/components/select/test_recorder.py @@ -5,11 +5,10 @@ from datetime import timedelta from homeassistant.components import select from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.select import ATTR_OPTIONS from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test select registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, select.DOMAIN, {select.DOMAIN: {"platform": "demo"}} ) @@ -27,21 +27,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_OPTIONS not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_OPTIONS not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/siren/test_recorder.py b/tests/components/siren/test_recorder.py index 2970754f8040..77b08135fabc 100644 --- a/tests/components/siren/test_recorder.py +++ b/tests/components/siren/test_recorder.py @@ -5,11 +5,10 @@ from datetime import timedelta from homeassistant.components import siren from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.siren import ATTR_AVAILABLE_TONES from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test siren registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, siren.DOMAIN, {siren.DOMAIN: {"platform": "demo"}} ) @@ -27,21 +27,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_AVAILABLE_TONES not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_AVAILABLE_TONES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/sun/test_recorder.py b/tests/components/sun/test_recorder.py index def5046c970e..c795a59a8e27 100644 --- a/tests/components/sun/test_recorder.py +++ b/tests/components/sun/test_recorder.py @@ -4,8 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.sun import ( DOMAIN, STATE_ATTR_AZIMUTH, @@ -19,7 +18,7 @@ from homeassistant.components.sun import ( STATE_ATTR_RISING, ) from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -29,35 +28,24 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test sun attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_sun_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_sun_states) - assert len(states) > 1 - for state in states: - assert STATE_ATTR_AZIMUTH not in state.attributes - assert STATE_ATTR_ELEVATION not in state.attributes - assert STATE_ATTR_NEXT_DAWN not in state.attributes - assert STATE_ATTR_NEXT_DUSK not in state.attributes - assert STATE_ATTR_NEXT_MIDNIGHT not in state.attributes - assert STATE_ATTR_NEXT_NOON not in state.attributes - assert STATE_ATTR_NEXT_RISING not in state.attributes - assert STATE_ATTR_NEXT_SETTING not in state.attributes - assert STATE_ATTR_RISING not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert STATE_ATTR_AZIMUTH not in state.attributes + assert STATE_ATTR_ELEVATION not in state.attributes + assert STATE_ATTR_NEXT_DAWN not in state.attributes + assert STATE_ATTR_NEXT_DUSK not in state.attributes + assert STATE_ATTR_NEXT_MIDNIGHT not in state.attributes + assert STATE_ATTR_NEXT_NOON not in state.attributes + assert STATE_ATTR_NEXT_RISING not in state.attributes + assert STATE_ATTR_NEXT_SETTING not in state.attributes + assert STATE_ATTR_RISING not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/text/test_recorder.py b/tests/components/text/test_recorder.py index c434a63ace15..b62baaac8183 100644 --- a/tests/components/text/test_recorder.py +++ b/tests/components/text/test_recorder.py @@ -5,11 +5,10 @@ from datetime import timedelta from homeassistant.components import text from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.text import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,28 +18,17 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test siren registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, text.DOMAIN, {text.DOMAIN: {"platform": "demo"}}) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - for attr in (ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN): - assert attr not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + for attr in (ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN): + assert attr not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index 628f5022afd7..c8fc62296a79 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -7,8 +7,7 @@ from unittest.mock import Mock from pyunifiprotect.data import Camera, Event, EventType from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.unifiprotect.binary_sensor import EVENT_SENSORS from homeassistant.components.unifiprotect.const import ( ATTR_EVENT_ID, @@ -16,7 +15,7 @@ from homeassistant.components.unifiprotect.const import ( DEFAULT_ATTRIBUTION, ) from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, STATE_ON, Platform -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from .utils import MockUFPFixture, ids_from_device_description, init_entry @@ -32,7 +31,7 @@ async def test_exclude_attributes( fixed_now: datetime, ) -> None: """Test binary_sensor has event_id and event_score excluded from recording.""" - + now = fixed_now await init_entry(hass, ufp, [doorbell, unadopted_camera]) _, entity_id = ids_from_device_description( @@ -70,22 +69,10 @@ async def test_exclude_attributes( assert state.attributes[ATTR_EVENT_SCORE] == 100 await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_EVENT_SCORE not in state.attributes - assert ATTR_EVENT_ID not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_EVENT_SCORE not in state.attributes + assert ATTR_EVENT_ID not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/update/test_recorder.py b/tests/components/update/test_recorder.py index 52158b0cc132..200cb4b45922 100644 --- a/tests/components/update/test_recorder.py +++ b/tests/components/update/test_recorder.py @@ -4,8 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.update.const import ( ATTR_IN_PROGRESS, ATTR_INSTALLED_VERSION, @@ -13,7 +12,7 @@ from homeassistant.components.update.const import ( DOMAIN, ) from homeassistant.const import ATTR_ENTITY_PICTURE, CONF_PLATFORM -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -25,6 +24,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None ) -> None: """Test update attributes to be excluded.""" + now = dt_util.utcnow() platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) @@ -42,23 +42,11 @@ async def test_exclude_attributes( await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_ENTITY_PICTURE not in state.attributes - assert ATTR_IN_PROGRESS not in state.attributes - assert ATTR_RELEASE_SUMMARY not in state.attributes - assert ATTR_INSTALLED_VERSION in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_ENTITY_PICTURE not in state.attributes + assert ATTR_IN_PROGRESS not in state.attributes + assert ATTR_RELEASE_SUMMARY not in state.attributes + assert ATTR_INSTALLED_VERSION in state.attributes diff --git a/tests/components/vacuum/test_recorder.py b/tests/components/vacuum/test_recorder.py index 9304cf985536..dc945f2c150e 100644 --- a/tests/components/vacuum/test_recorder.py +++ b/tests/components/vacuum/test_recorder.py @@ -5,11 +5,10 @@ from datetime import timedelta from homeassistant.components import vacuum from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.vacuum import ATTR_FAN_SPEED_LIST from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -19,6 +18,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test vacuum registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, vacuum.DOMAIN, {vacuum.DOMAIN: {"platform": "demo"}} ) @@ -27,21 +27,9 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_FAN_SPEED_LIST not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_FAN_SPEED_LIST not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/water_heater/test_recorder.py b/tests/components/water_heater/test_recorder.py index c77ace9f1296..febe2fd7df82 100644 --- a/tests/components/water_heater/test_recorder.py +++ b/tests/components/water_heater/test_recorder.py @@ -5,15 +5,14 @@ from datetime import timedelta from homeassistant.components import water_heater from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.water_heater import ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_OPERATION_LIST, ) from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -23,6 +22,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test water_heater registered attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component( hass, water_heater.DOMAIN, {water_heater.DOMAIN: {"platform": "demo"}} ) @@ -31,23 +31,11 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_OPERATION_LIST not in state.attributes - assert ATTR_MIN_TEMP not in state.attributes - assert ATTR_MAX_TEMP not in state.attributes - assert ATTR_FRIENDLY_NAME in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_OPERATION_LIST not in state.attributes + assert ATTR_MIN_TEMP not in state.attributes + assert ATTR_MAX_TEMP not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 4f873710421d..04ae04a044c4 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -4,10 +4,9 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.db_schema import StateAttributes, States -from homeassistant.components.recorder.util import session_scope +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.weather import ATTR_FORECAST, DOMAIN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM @@ -18,6 +17,7 @@ from tests.components.recorder.common import async_wait_recording_done async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test weather attributes to be excluded.""" + now = dt_util.utcnow() await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) hass.config.units = METRIC_SYSTEM await hass.async_block_till_done() @@ -30,20 +30,8 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) await hass.async_block_till_done() await async_wait_recording_done(hass) - def _fetch_states() -> list[State]: - with session_scope(hass=hass) as session: - native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ): - state = db_state.to_native() - state.attributes = db_state_attributes.to_native() - native_states.append(state) - return native_states - - states: list[State] = await hass.async_add_executor_job(_fetch_states) - assert len(states) > 1 - for state in states: - assert ATTR_FORECAST not in state.attributes + states = await hass.async_add_executor_job(get_significant_states, hass, now) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_FORECAST not in state.attributes From 84327f203cecee4f6a01770d0bc8a64db29078d0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 16:52:17 -1000 Subject: [PATCH 0399/1058] Fix flux_led set time to not happen during DST switch (#89559) * Fix flux_led set time test If this test was run at the wrong time of the day it would not have been long enough for the set time to fire since it only happens at 2:40:30 in the morning local time * Revert "Fix flux_led set time test" This reverts commit 3241912eff951c8cd5fe45dbff381f0ca6b8926c. * Change time set to not be during DST switch --- homeassistant/components/flux_led/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 86b73c762fb6..e6f89536baf6 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -209,7 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await device.async_set_time() await _async_sync_time() # set at startup - entry.async_on_unload(async_track_time_change(hass, _async_sync_time, 2, 40, 30)) + entry.async_on_unload(async_track_time_change(hass, _async_sync_time, 3, 40, 30)) # There must not be any awaits between here and the return # to avoid a race condition where the add_update_listener is not From edb06c58fa5bb523d877135cdcf33f1d78713fb9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Mar 2023 18:04:19 -1000 Subject: [PATCH 0400/1058] Add some more typing to screenlogic (#88522) --- .../components/screenlogic/__init__.py | 29 ++++++------ .../components/screenlogic/entity.py | 45 ++++++++++++------- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index ad2f9c64f3ee..a0accef07990 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -90,23 +90,23 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None await hass.config_entries.async_reload(entry.entry_id) -async def async_get_connect_info(hass: HomeAssistant, entry: ConfigEntry): +async def async_get_connect_info( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, str | int]: """Construct connect_info from configuration entry and returns it to caller.""" mac = entry.unique_id # Attempt to rediscover gateway to follow IP changes discovered_gateways = await async_discover_gateways_by_unique_id(hass) if mac in discovered_gateways: - connect_info = discovered_gateways[mac] - else: - _LOGGER.warning("Gateway rediscovery failed") - # Static connection defined or fallback from discovery - connect_info = { - SL_GATEWAY_NAME: name_for_mac(mac), - SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], - SL_GATEWAY_PORT: entry.data[CONF_PORT], - } + return discovered_gateways[mac] - return connect_info + _LOGGER.warning("Gateway rediscovery failed") + # Static connection defined or fallback from discovery + return { + SL_GATEWAY_NAME: name_for_mac(mac), + SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], + SL_GATEWAY_PORT: entry.data[CONF_PORT], + } class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): @@ -143,7 +143,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): """Return the gateway data.""" return self.gateway.get_data() - async def _async_update_configured_data(self): + async def _async_update_configured_data(self) -> None: """Update data sets based on equipment config.""" equipment_flags = self.gateway.get_data()[SL_DATA.KEY_CONFIG]["equipment_flags"] if not self.gateway.is_client: @@ -155,7 +155,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: await self.gateway.async_get_scg() - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data from the Screenlogic gateway.""" try: await self._async_update_configured_data() @@ -165,8 +165,9 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): return None - async def _async_reconnect_update_data(self): + async def _async_reconnect_update_data(self) -> None: """Attempt to reconnect to the gateway and fetch data.""" + assert self.config_entry is not None try: # Clean up the previous connection as we're about to create a new one await self.gateway.async_disconnect() diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 80b4df4d2de7..4ea23395c5a2 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,9 +1,10 @@ """Base ScreenLogicEntity definitions.""" +from datetime import datetime import logging from typing import Any -# from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const import DATA as SL_DATA, EQUIPMENT, ON_OFF +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, ON_OFF from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -19,7 +20,12 @@ _LOGGER = logging.getLogger(__name__) class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" - def __init__(self, coordinator, data_key, enabled=True): + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + data_key: str, + enabled: bool = True, + ) -> None: """Initialize of the entity.""" super().__init__(coordinator) self._data_key = data_key @@ -34,8 +40,10 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): ] except KeyError: equipment_model = f"Unknown Model C:{controller_type} H:{hardware_type}" + mac = self.mac + assert mac is not None self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.mac)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, manufacturer="Pentair", model=equipment_model, name=self.gateway_name, @@ -43,17 +51,18 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): ) @property - def mac(self): + def mac(self) -> str | None: """Mac address.""" + assert self.coordinator.config_entry is not None return self.coordinator.config_entry.unique_id @property - def config_data(self): + def config_data(self) -> dict[str | int, Any]: """Shortcut for config data.""" return self.gateway_data[SL_DATA.KEY_CONFIG] @property - def gateway(self): + def gateway(self) -> ScreenLogicGateway: """Return the gateway.""" return self.coordinator.gateway @@ -63,18 +72,18 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): return self.gateway.get_data() @property - def gateway_name(self): + def gateway_name(self) -> str: """Return the configured name of the gateway.""" return self.gateway.name - async def _async_refresh(self): + async def _async_refresh(self) -> None: """Refresh the data from the gateway.""" await self.coordinator.async_refresh() # Second debounced refresh to catch any secondary # changes in the device await self.coordinator.async_request_refresh() - async def _async_refresh_timed(self, now): + async def _async_refresh_timed(self, now: datetime) -> None: """Refresh from a timed called.""" await self.coordinator.async_request_refresh() @@ -82,7 +91,13 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): class ScreenLogicPushEntity(ScreenlogicEntity): """Base class for all ScreenLogic push entities.""" - def __init__(self, coordinator, data_key, message_code, enabled=True): + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + data_key: str, + message_code: CODE, + enabled: bool = True, + ) -> None: """Initialize the entity.""" super().__init__(coordinator, data_key, enabled) self._update_message_code = message_code @@ -108,7 +123,7 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): _attr_has_entity_name = True @property - def name(self): + def name(self) -> str: """Get the name of the switch.""" return self.circuit["name"] @@ -117,15 +132,15 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Get whether the switch is in on state.""" return self.circuit["value"] == ON_OFF.ON - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" await self._async_set_circuit(ON_OFF.ON) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Send the OFF command.""" await self._async_set_circuit(ON_OFF.OFF) - async def _async_set_circuit(self, circuit_value) -> None: + async def _async_set_circuit(self, circuit_value: int) -> None: if not await self.gateway.async_set_circuit(self._data_key, circuit_value): raise HomeAssistantError( f"Failed to set_circuit {self._data_key} {circuit_value}" From 234610b1cc918a5a0c562cd07167459677ac9b18 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Mar 2023 14:47:43 +0100 Subject: [PATCH 0401/1058] Simplify command_line sensor tests (#89576) --- tests/components/command_line/test_sensor.py | 27 ++++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 347c6a7ffda8..f7de3b339442 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -7,26 +7,19 @@ from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er async def setup_test_entities(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: """Set up a test command line sensor entity.""" + hass.states.async_set("sensor.input_sensor", "sensor_value") assert await setup.async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - DOMAIN: [ - { - "platform": "template", - "sensors": { - "template_sensor": { - "value_template": "template_value", - } - }, - }, + SENSOR_DOMAIN: [ {"platform": "command_line", "name": "Test", **config_dict}, ] }, @@ -71,12 +64,12 @@ async def test_template_render(hass: HomeAssistant) -> None: await setup_test_entities( hass, { - "command": "echo {{ states.sensor.template_sensor.state }}", + "command": "echo {{ states.sensor.input_sensor.state }}", }, ) entity_state = hass.states.get("sensor.test") assert entity_state - assert entity_state.state == "template_value" + assert entity_state.state == "sensor_value" async def test_template_render_with_quote(hass: HomeAssistant) -> None: @@ -89,12 +82,12 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: await setup_test_entities( hass, { - "command": 'echo "{{ states.sensor.template_sensor.state }}" "3 4"', + "command": 'echo "{{ states.sensor.input_sensor.state }}" "3 4"', }, ) check_output.assert_called_once_with( - 'echo "template_value" "3 4"', + 'echo "sensor_value" "3 4"', shell=True, # nosec # shell by design timeout=15, close_fds=False, @@ -266,9 +259,9 @@ async def test_unique_id( """Test unique_id option and if it only creates one sensor per id.""" assert await setup.async_setup_component( hass, - DOMAIN, + SENSOR_DOMAIN, { - DOMAIN: [ + SENSOR_DOMAIN: [ { "platform": "command_line", "unique_id": "unique", From 376a6eb82ad8c73cbbe664d3bc2a5c70a0bbfaa1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Mar 2023 14:48:46 +0100 Subject: [PATCH 0402/1058] Convert device_sun_light_trigger test fixture to async (#89578) --- .../device_sun_light_trigger/test_init.py | 23 +++++++++---------- .../custom_components/test/device_tracker.py | 2 +- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 0c5a9ccab145..6b563f1cb5fd 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -28,9 +28,11 @@ from tests.common import async_fire_time_changed @pytest.fixture -def scanner(hass, enable_custom_integrations): +async def scanner(hass, enable_custom_integrations): """Initialize components.""" - scanner = getattr(hass.components, "test.device_tracker").get_scanner(None, None) + scanner = await getattr(hass.components, "test.device_tracker").async_get_scanner( + None, None + ) scanner.reset() scanner.come_home("DEV1") @@ -56,19 +58,16 @@ def scanner(hass, enable_custom_integrations): }, }, ): - assert hass.loop.run_until_complete( - async_setup_component( - hass, - device_tracker.DOMAIN, - {device_tracker.DOMAIN: {CONF_PLATFORM: "test"}}, - ) + assert await async_setup_component( + hass, + device_tracker.DOMAIN, + {device_tracker.DOMAIN: {CONF_PLATFORM: "test"}}, ) - assert hass.loop.run_until_complete( - async_setup_component( - hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} - ) + assert await async_setup_component( + hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) + await hass.async_block_till_done() return scanner diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py index d5f34f48ec8b..31294a48e3d5 100644 --- a/tests/testing_config/custom_components/test/device_tracker.py +++ b/tests/testing_config/custom_components/test/device_tracker.py @@ -5,7 +5,7 @@ from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER -def get_scanner(hass, config): +async def async_get_scanner(hass, config): """Return a mock scanner.""" return SCANNER From cf7e500a8eee524ddafd553bfdb174a1c408a3a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 12 Mar 2023 15:55:04 +0100 Subject: [PATCH 0403/1058] Support translating entity names (#88242) --- .github/workflows/ci.yaml | 8 ++++++++ homeassistant/components/demo/sensor.py | 9 ++++++--- homeassistant/components/demo/strings.json | 1 + homeassistant/helpers/entity.py | 9 +++++++++ homeassistant/helpers/entity_platform.py | 11 +++++++++++ script/hassfest/translations.py | 1 + tests/components/rest/test_binary_sensor.py | 1 + tests/components/rest/test_sensor.py | 1 + tests/components/sensor/test_recorder.py | 2 ++ 9 files changed, 40 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b65039c42bfe..86972558882a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1003,6 +1003,10 @@ jobs: run: | . venv/bin/activate pip install mysqlclient sqlalchemy_utils + - name: Compile English translations + run: | + . venv/bin/activate + python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 shell: bash @@ -1107,6 +1111,10 @@ jobs: run: | . venv/bin/activate pip install psycopg2 sqlalchemy_utils + - name: Compile English translations + run: | + . venv/bin/activate + python3 -m script.translations develop --all - name: Run pytest (partially) timeout-minutes: 20 shell: bash diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 67a7b346a3e3..84758f0c294d 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -126,7 +126,7 @@ async def async_setup_platform( ), DemoSensor( unique_id="sensor_10", - name="Thermostat mode", + name=None, state="eco", device_class=SensorDeviceClass.ENUM, state_class=None, @@ -156,7 +156,7 @@ class DemoSensor(SensorEntity): def __init__( self, unique_id: str, - name: str, + name: str | None, state: StateType, device_class: SensorDeviceClass, state_class: SensorStateClass | None, @@ -167,7 +167,10 @@ class DemoSensor(SensorEntity): ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class - self._attr_name = name + if name is not None: + self._attr_name = name + else: + self._attr_has_entity_name = True self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_state_class = state_class diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index cdbe8dc1bd53..add04c236e76 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -98,6 +98,7 @@ }, "sensor": { "thermostat_mode": { + "name": "Thermostat mode", "state": { "away": "Away", "comfort": "Comfort", diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9c1bbe5b209e..63b70aa13d93 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -319,6 +319,15 @@ class Entity(ABC): """Return the name of the entity.""" if hasattr(self, "_attr_name"): return self._attr_name + if self.translation_key is not None and self.has_entity_name: + assert self.platform + name_translation_key = ( + f"component.{self.platform.platform_name}.entity.{self.platform.domain}" + f".{self.translation_key}.name" + ) + if name_translation_key in self.platform.entity_translations: + name: str = self.platform.entity_translations[name_translation_key] + return name if hasattr(self, "entity_description"): return self.entity_description.name return None diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index e085f819e3c8..6687af6a27d6 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -39,6 +39,7 @@ from . import ( device_registry as dev_reg, entity_registry as ent_reg, service, + translation, ) from .device_registry import DeviceRegistry from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider @@ -124,6 +125,7 @@ class EntityPlatform: self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None self.entities: dict[str, Entity] = {} + self.entity_translations: dict[str, Any] = {} self._tasks: list[asyncio.Task[None]] = [] # Stop tracking tasks after setup is completed self._setup_complete = False @@ -276,6 +278,15 @@ class EntityPlatform: hass = self.hass full_name = f"{self.domain}.{self.platform_name}" + try: + self.entity_translations = await translation.async_get_translations( + hass, hass.config.language, "entity", {self.platform_name} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", self.platform_name, exc_info=err + ) + logger.info("Setting up %s", full_name) warn_task = hass.loop.call_later( SLOW_SETUP_WARNING, diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 5233911111e4..92a1047c304b 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -286,6 +286,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("entity"): { str: { str: { + vol.Optional("name"): cv.string_with_no_html, vol.Optional("state_attributes"): { str: { vol.Optional("name"): cv.string_with_no_html, diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 757c331529ef..99d378983b9f 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -214,6 +214,7 @@ async def test_setup_get_template_headers_params(hass: HomeAssistant) -> None: }, ) await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() assert respx.calls.last.request.headers["Accept"] == CONTENT_TYPE_JSON assert respx.calls.last.request.headers["User-Agent"] == "Mozilla/5.0" diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 5dcaed6985d3..46a972628e5c 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -318,6 +318,7 @@ async def test_setup_get_templated_headers_params(hass: HomeAssistant) -> None: }, ) await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() assert respx.calls.last.request.headers["Accept"] == CONTENT_TYPE_JSON assert respx.calls.last.request.headers["User-Agent"] == "Mozilla/5.0" diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index add7bc5e0160..55eda6c03b01 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4748,5 +4748,7 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) states: list[State] = await hass.async_add_executor_job(_fetch_states) assert len(states) > 1 for state in states: + if state.domain != DOMAIN: + continue assert ATTR_OPTIONS not in state.attributes assert ATTR_FRIENDLY_NAME in state.attributes From e9321397210c853345f1c8c68c9119c9261fface Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 12 Mar 2023 17:10:00 +0100 Subject: [PATCH 0404/1058] Strict typing threshold (#82786) --- .strict-typing | 1 + .../components/threshold/binary_sensor.py | 92 +++++++++++-------- .../components/threshold/config_flow.py | 3 +- mypy.ini | 10 ++ .../threshold/test_binary_sensor.py | 18 ++++ 5 files changed, 86 insertions(+), 38 deletions(-) diff --git a/.strict-typing b/.strict-typing index b33eab5bd65d..9db95008927e 100644 --- a/.strict-typing +++ b/.strict-typing @@ -297,6 +297,7 @@ homeassistant.components.tag.* homeassistant.components.tailscale.* homeassistant.components.tautulli.* homeassistant.components.tcp.* +homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* homeassistant.components.tilt_ble.* diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 8cec85bf20da..0badf7eb41fb 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -19,7 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -93,12 +95,15 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Threshold sensor.""" - entity_id = config.get(CONF_ENTITY_ID) - name = config.get(CONF_NAME) - lower = config.get(CONF_LOWER) - upper = config.get(CONF_UPPER) - hysteresis = config.get(CONF_HYSTERESIS) - device_class = config.get(CONF_DEVICE_CLASS) + entity_id: str = config[CONF_ENTITY_ID] + name: str = config[CONF_NAME] + lower: float | None = config.get(CONF_LOWER) + upper: float | None = config.get(CONF_UPPER) + hysteresis: float = config[CONF_HYSTERESIS] + device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) + + if lower is None and upper is None: + raise ValueError("Lower or Upper thresholds not provided") async_add_entities( [ @@ -115,22 +120,29 @@ class ThresholdSensor(BinarySensorEntity): _attr_should_poll = False def __init__( - self, hass, entity_id, name, lower, upper, hysteresis, device_class, unique_id - ): + self, + hass: HomeAssistant, + entity_id: str, + name: str, + lower: float | None, + upper: float | None, + hysteresis: float, + device_class: BinarySensorDeviceClass | None, + unique_id: str | None, + ) -> None: """Initialize the Threshold sensor.""" self._attr_unique_id = unique_id self._entity_id = entity_id self._name = name self._threshold_lower = lower self._threshold_upper = upper - self._hysteresis = hysteresis + self._hysteresis: float = hysteresis self._device_class = device_class - self._state_position = POSITION_UNKNOWN - self._state = None - self.sensor_value = None + self._state: bool | None = None + self.sensor_value: float | None = None - def _update_sensor_state(): + def _update_sensor_state() -> None: """Handle sensor state changes.""" if (new_state := hass.states.get(self._entity_id)) is None: return @@ -148,7 +160,7 @@ class ThresholdSensor(BinarySensorEntity): self._update_state() @callback - def async_threshold_sensor_state_listener(event): + def async_threshold_sensor_state_listener(event: Event) -> None: """Handle sensor state changes.""" _update_sensor_state() self.async_write_ha_state() @@ -161,32 +173,31 @@ class ThresholdSensor(BinarySensorEntity): _update_sensor_state() @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass | None: """Return the sensor class of the sensor.""" return self._device_class @property - def threshold_type(self): + def threshold_type(self) -> str: """Return the type of threshold this sensor represents.""" if self._threshold_lower is not None and self._threshold_upper is not None: return TYPE_RANGE if self._threshold_lower is not None: return TYPE_LOWER - if self._threshold_upper is not None: - return TYPE_UPPER + return TYPE_UPPER @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, @@ -199,44 +210,51 @@ class ThresholdSensor(BinarySensorEntity): } @callback - def _update_state(self): + def _update_state(self) -> None: """Update the state.""" - def below(threshold): + def below(sensor_value: float, threshold: float) -> bool: """Determine if the sensor value is below a threshold.""" - return self.sensor_value < (threshold - self._hysteresis) + return sensor_value < (threshold - self._hysteresis) - def above(threshold): + def above(sensor_value: float, threshold: float) -> bool: """Determine if the sensor value is above a threshold.""" - return self.sensor_value > (threshold + self._hysteresis) + return sensor_value > (threshold + self._hysteresis) if self.sensor_value is None: self._state_position = POSITION_UNKNOWN self._state = False + return - elif self.threshold_type == TYPE_LOWER: - if below(self._threshold_lower): + if self.threshold_type == TYPE_LOWER and self._threshold_lower is not None: + if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = True - elif above(self._threshold_lower): + elif above(self.sensor_value, self._threshold_lower): self._state_position = POSITION_ABOVE self._state = False - elif self.threshold_type == TYPE_UPPER: - if above(self._threshold_upper): + if self.threshold_type == TYPE_UPPER and self._threshold_upper is not None: + if above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = True - elif below(self._threshold_upper): + elif below(self.sensor_value, self._threshold_upper): self._state_position = POSITION_BELOW self._state = False - elif self.threshold_type == TYPE_RANGE: - if below(self._threshold_lower): + if ( + self.threshold_type == TYPE_RANGE + and self._threshold_lower is not None + and self._threshold_upper is not None + ): + if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = False - if above(self._threshold_upper): + if above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = False - elif above(self._threshold_lower) and below(self._threshold_upper): + elif above(self.sensor_value, self._threshold_lower) and below( + self.sensor_value, self._threshold_upper + ): self._state_position = POSITION_IN_RANGE self._state = True diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index fbb12872306c..31d51fee3f37 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -76,4 +76,5 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" - return options[CONF_NAME] + name: str = options[CONF_NAME] + return name diff --git a/mypy.ini b/mypy.ini index 6d32c16b96b9..760c7f6811df 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2733,6 +2733,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.threshold.*] +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.tibber.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index f009e4c48a20..eed3a8a40e01 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -1,4 +1,5 @@ """The test for the threshold sensor platform.""" + import pytest from homeassistant.const import ( @@ -567,3 +568,20 @@ async def test_sensor_upper_zero_threshold(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.state == "on" + + +async def test_sensor_no_lower_upper( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test if no lower or upper has been provided.""" + config = { + "binary_sensor": { + "platform": "threshold", + "entity_id": "sensor.test_monitored", + } + } + + await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + assert "Lower or Upper thresholds not provided" in caplog.text From b4b7605b82892a43fc5d24bf34bc9364d57e397b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 12 Mar 2023 17:38:26 +0100 Subject: [PATCH 0405/1058] Improve screenlogic generic typing (#89587) --- homeassistant/components/screenlogic/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index a0accef07990..5838031dc63a 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -109,7 +109,7 @@ async def async_get_connect_info( } -class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): +class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): """Class to manage the data update for the Screenlogic component.""" def __init__( From 73cd62bd32055330637296c0b3991280a9596655 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 06:39:07 -1000 Subject: [PATCH 0406/1058] Fix lingering tasks in google_wifi tests (#89571) --- tests/components/google_wifi/test_sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index ab5b703fd797..868631e9c27c 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -44,6 +44,7 @@ async def test_setup_minimum( "sensor", {"sensor": {"platform": "google_wifi", "monitored_conditions": ["uptime"]}}, ) + await hass.async_block_till_done() assert_setup_component(1, "sensor") @@ -72,6 +73,7 @@ async def test_setup_get( } }, ) + await hass.async_block_till_done() assert_setup_component(6, "sensor") From 8d88b02c2eaa97906efd1154b956e2702429eb5f Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+j-stienstra@users.noreply.github.com> Date: Sun, 12 Mar 2023 19:31:10 +0100 Subject: [PATCH 0407/1058] Recode Home Assistant instance name to ascii for Jellyfin (#87368) Recode instance name to ascii --- homeassistant/components/jellyfin/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 565c106f6aee..4ee970207246 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -20,10 +20,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry_data[CONF_CLIENT_DEVICE_ID] = entry.entry_id hass.config_entries.async_update_entry(entry, data=entry_data) - client = create_client( - device_id=entry.data[CONF_CLIENT_DEVICE_ID], - device_name=hass.config.location_name, - ) + device_id = entry.data[CONF_CLIENT_DEVICE_ID] + device_name = ascii(hass.config.location_name) + + client = create_client(device_id=device_id, device_name=device_name) try: user_id, connect_result = await validate_input(hass, dict(entry.data), client) From c41f91be896978f2d18d2c2b4d039dc41457e9a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 10:01:58 -1000 Subject: [PATCH 0408/1058] Deduplicate entity_id in the states table (#89557) --- homeassistant/components/logbook/processor.py | 31 +- .../components/logbook/queries/__init__.py | 10 +- .../components/logbook/queries/common.py | 11 +- .../components/logbook/queries/devices.py | 2 + .../components/logbook/queries/entities.py | 32 +- .../logbook/queries/entities_and_devices.py | 20 +- homeassistant/components/recorder/core.py | 93 +- .../components/recorder/db_schema.py | 33 +- homeassistant/components/recorder/filters.py | 23 +- .../components/recorder/history/__init__.py | 173 ++- .../components/recorder/history/modern.py | 783 +++++++++++++ .../components/recorder/migration.py | 101 +- .../components/recorder/models/state.py | 4 +- .../recorder/models/state_attributes.py | 8 +- homeassistant/components/recorder/purge.py | 61 +- homeassistant/components/recorder/queries.py | 96 ++ .../recorder/table_managers/states_meta.py | 94 ++ homeassistant/components/recorder/tasks.py | 36 + .../history/test_init_db_schema_30.py | 1036 +++++++++-------- .../db_schema_23_with_newer_columns.py | 27 + tests/components/recorder/db_schema_28.py | 35 +- tests/components/recorder/db_schema_30.py | 34 + .../test_filters_with_entityfilter.py | 6 +- ...est_filters_with_entityfilter_schema_37.py | 670 +++++++++++ tests/components/recorder/test_history.py | 75 +- .../recorder/test_history_db_schema_30.py | 656 ++++++----- tests/components/recorder/test_init.py | 127 +- tests/components/recorder/test_migrate.py | 132 ++- tests/components/recorder/test_purge.py | 152 ++- tests/components/recorder/test_util.py | 13 +- .../components/recorder/test_v32_migration.py | 102 +- tests/components/sensor/test_recorder.py | 13 +- tests/conftest.py | 44 + 33 files changed, 3715 insertions(+), 1018 deletions(-) create mode 100644 homeassistant/components/recorder/history/modern.py create mode 100644 homeassistant/components/recorder/table_managers/states_meta.py create mode 100644 tests/components/recorder/test_filters_with_entityfilter_schema_37.py diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index f816064ba69e..aa0bc7495888 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -10,6 +10,7 @@ from typing import Any from sqlalchemy.engine import Result from sqlalchemy.engine.row import Row +from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( bytes_to_uuid_hex_or_none, @@ -149,16 +150,28 @@ class EventProcessor: # return result.yield_per(1024) - stmt = statement_for_request( - start_day, - end_day, - self.event_types, - self.entity_ids, - self.device_ids, - self.filters, - self.context_id, - ) with session_scope(hass=self.hass) as session: + metadata_ids: list[int] | None = None + if self.entity_ids: + instance = get_instance(self.hass) + entity_id_to_metadata_id = instance.states_meta_manager.get_many( + self.entity_ids, session + ) + metadata_ids = [ + metadata_id + for metadata_id in entity_id_to_metadata_id.values() + if metadata_id is not None + ] + stmt = statement_for_request( + start_day, + end_day, + self.event_types, + self.entity_ids, + metadata_ids, + self.device_ids, + self.filters, + self.context_id, + ) return self.humanify(yield_rows(session.execute(stmt))) def humanify( diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index b88fd4842cd0..cfef16bf7735 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -1,6 +1,7 @@ """Queries for logbook.""" from __future__ import annotations +from collections.abc import Collection from datetime import datetime as dt from sqlalchemy.sql.lambdas import StatementLambdaElement @@ -21,6 +22,7 @@ def statement_for_request( end_day_dt: dt, event_types: tuple[str, ...], entity_ids: list[str] | None = None, + states_metadata_ids: Collection[int] | None = None, device_ids: list[str] | None = None, filters: Filters | None = None, context_id: str | None = None, @@ -32,7 +34,9 @@ def statement_for_request( # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: - states_entity_filter = filters.states_entity_filter() if filters else None + states_entity_filter = ( + filters.states_metadata_entity_filter() if filters else None + ) events_entity_filter = filters.events_entity_filter() if filters else None return all_stmt( start_day, @@ -56,7 +60,7 @@ def statement_for_request( start_day, end_day, event_types, - entity_ids, + states_metadata_ids or [], json_quoted_entity_ids, json_quoted_device_ids, ) @@ -68,7 +72,7 @@ def statement_for_request( start_day, end_day, event_types, - entity_ids, + states_metadata_ids or [], json_quoted_entity_ids, ) diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 8645c8f68cbd..c63bb30eb6c8 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -20,6 +20,7 @@ from homeassistant.components.recorder.db_schema import ( EventTypes, StateAttributes, States, + StatesMeta, ) from homeassistant.components.recorder.filters import like_domain_matchers from homeassistant.components.recorder.queries import select_event_type_ids @@ -57,7 +58,7 @@ EVENT_COLUMNS = ( STATE_COLUMNS = ( States.state_id.label("state_id"), States.state.label("state"), - States.entity_id.label("entity_id"), + StatesMeta.entity_id.label("entity_id"), SHARED_ATTRS_JSON["icon"].as_string().label("icon"), OLD_FORMAT_ATTRS_JSON["icon"].as_string().label("old_format_icon"), ) @@ -65,7 +66,7 @@ STATE_COLUMNS = ( STATE_CONTEXT_ONLY_COLUMNS = ( States.state_id.label("state_id"), States.state.label("state"), - States.entity_id.label("entity_id"), + StatesMeta.entity_id.label("entity_id"), literal(value=None, type_=sqlalchemy.String).label("icon"), literal(value=None, type_=sqlalchemy.String).label("old_format_icon"), ) @@ -186,6 +187,7 @@ def legacy_select_events_context_id( .outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) .where(Events.context_id_bin == context_id_bin) @@ -213,6 +215,7 @@ def apply_states_filters(sel: Select, start_day: float, end_day: float) -> Selec .outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ) @@ -249,7 +252,7 @@ def _not_possible_continuous_domain_matcher() -> ColumnElement[bool]: """ return sqlalchemy.and_( *[ - ~States.entity_id.like(entity_domain) + ~StatesMeta.entity_id.like(entity_domain) for entity_domain in ( *ALWAYS_CONTINUOUS_ENTITY_ID_LIKE, *CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE, @@ -266,7 +269,7 @@ def _conditionally_continuous_domain_matcher() -> ColumnElement[bool]: """ return sqlalchemy.or_( *[ - States.entity_id.like(entity_domain) + StatesMeta.entity_id.like(entity_domain) for entity_domain in CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE ], ).self_group() diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 687c48b89212..a5c06dc84cf7 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -15,6 +15,7 @@ from homeassistant.components.recorder.db_schema import ( Events, EventTypes, States, + StatesMeta, ) from .common import ( @@ -68,6 +69,7 @@ def _apply_devices_context_union( select_states_context_only() .select_from(devices_cte) .outerjoin(States, devices_cte.c.context_id_bin == States.context_id_bin) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ), ) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index e0ae32b6694d..ebb56befa50a 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -1,7 +1,7 @@ """Entities queries for logbook.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Collection, Iterable import sqlalchemy from sqlalchemy import lambda_stmt, select, union_all @@ -11,12 +11,13 @@ from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select from homeassistant.components.recorder.db_schema import ( ENTITY_ID_IN_EVENT, - ENTITY_ID_LAST_UPDATED_INDEX_TS, + METADATA_ID_LAST_UPDATED_INDEX_TS, OLD_ENTITY_ID_IN_EVENT, EventData, Events, EventTypes, States, + StatesMeta, ) from .common import ( @@ -35,7 +36,7 @@ def _select_entities_context_ids_sub_query( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> Select: """Generate a subquery to find context ids for multiple entities.""" @@ -47,7 +48,7 @@ def _select_entities_context_ids_sub_query( .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) - .where(States.entity_id.in_(entity_ids)), + .where(States.metadata_id.in_(states_metadata_ids)), ).subquery() return select(union.c.context_id_bin).group_by(union.c.context_id_bin) @@ -57,7 +58,7 @@ def _apply_entities_context_union( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the entity and device context ids and a query to find linked row.""" @@ -65,16 +66,16 @@ def _apply_entities_context_union( start_day, end_day, event_types, - entity_ids, + states_metadata_ids, json_quoted_entity_ids, ).cte() # We used to optimize this to exclude rows we already in the union with - # a States.entity_id.not_in(entity_ids) but that made the + # a StatesMeta.metadata_ids.not_in(states_metadata_ids) but that made the # query much slower on MySQL, and since we already filter them away # in the python code anyways since they will have context_only # set on them the impact is minimal. return sel.union_all( - states_select_for_entity_ids(start_day, end_day, entity_ids), + states_select_for_entity_ids(start_day, end_day, states_metadata_ids), apply_events_context_hints( select_events_context_only() .select_from(entities_cte) @@ -86,6 +87,7 @@ def _apply_entities_context_union( select_states_context_only() .select_from(entities_cte) .outerjoin(States, entities_cte.c.context_id_bin == States.context_id_bin) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ), ) @@ -94,7 +96,7 @@ def entities_stmt( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], ) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" @@ -106,19 +108,19 @@ def entities_stmt( start_day, end_day, event_types, - entity_ids, + states_metadata_ids, json_quoted_entity_ids, ).order_by(Events.time_fired_ts) ) def states_select_for_entity_ids( - start_day: float, end_day: float, entity_ids: list[str] + start_day: float, end_day: float, states_metadata_ids: Collection[int] ) -> Select: """Generate a select for states from the States table for specific entities.""" return apply_states_filters( apply_entities_hints(select_states()), start_day, end_day - ).where(States.entity_id.in_(entity_ids)) + ).where(States.metadata_id.in_(states_metadata_ids)) def apply_event_entity_id_matchers( @@ -140,9 +142,11 @@ def apply_event_entity_id_matchers( def apply_entities_hints(sel: Select) -> Select: """Force mysql to use the right index on large selects.""" return sel.with_hint( - States, f"FORCE INDEX ({ENTITY_ID_LAST_UPDATED_INDEX_TS})", dialect_name="mysql" + States, + f"FORCE INDEX ({METADATA_ID_LAST_UPDATED_INDEX_TS})", + dialect_name="mysql", ).with_hint( States, - f"FORCE INDEX ({ENTITY_ID_LAST_UPDATED_INDEX_TS})", + f"FORCE INDEX ({METADATA_ID_LAST_UPDATED_INDEX_TS})", dialect_name="mariadb", ) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 677feddda848..f7ffde4f81a7 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -1,7 +1,7 @@ """Entities and Devices queries for logbook.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Collection, Iterable from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.sql.elements import ColumnElement @@ -13,6 +13,7 @@ from homeassistant.components.recorder.db_schema import ( Events, EventTypes, States, + StatesMeta, ) from .common import ( @@ -35,7 +36,7 @@ def _select_entities_device_id_context_ids_sub_query( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> Select: @@ -50,7 +51,7 @@ def _select_entities_device_id_context_ids_sub_query( .filter( (States.last_updated_ts > start_day) & (States.last_updated_ts < end_day) ) - .where(States.entity_id.in_(entity_ids)), + .where(States.metadata_id.in_(states_metadata_ids)), ).subquery() return select(union.c.context_id_bin).group_by(union.c.context_id_bin) @@ -60,7 +61,7 @@ def _apply_entities_devices_context_union( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> CompoundSelect: @@ -68,17 +69,17 @@ def _apply_entities_devices_context_union( start_day, end_day, event_types, - entity_ids, + states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, ).cte() # We used to optimize this to exclude rows we already in the union with - # a States.entity_id.not_in(entity_ids) but that made the + # a States.metadata_id.not_in(states_metadata_ids) but that made the # query much slower on MySQL, and since we already filter them away # in the python code anyways since they will have context_only # set on them the impact is minimal. return sel.union_all( - states_select_for_entity_ids(start_day, end_day, entity_ids), + states_select_for_entity_ids(start_day, end_day, states_metadata_ids), apply_events_context_hints( select_events_context_only() .select_from(devices_entities_cte) @@ -94,6 +95,7 @@ def _apply_entities_devices_context_union( .outerjoin( States, devices_entities_cte.c.context_id_bin == States.context_id_bin ) + .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) ), ) @@ -102,7 +104,7 @@ def entities_devices_stmt( start_day: float, end_day: float, event_types: tuple[str, ...], - entity_ids: list[str], + states_metadata_ids: Collection[int], json_quoted_entity_ids: list[str], json_quoted_device_ids: list[str], ) -> StatementLambdaElement: @@ -117,7 +119,7 @@ def entities_devices_stmt( start_day, end_day, event_types, - entity_ids, + states_metadata_ids, json_quoted_entity_ids, json_quoted_device_ids, ).order_by(Events.time_fired_ts) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 97d72c7f85cf..630efe195607 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -64,6 +64,7 @@ from .db_schema import ( EventTypes, StateAttributes, States, + StatesMeta, Statistics, StatisticsRuns, StatisticsShortTerm, @@ -82,10 +83,14 @@ from .queries import ( find_shared_data_id, get_shared_attributes, get_shared_event_datas, + has_entity_ids_to_migrate, has_event_type_to_migrate, + has_events_context_ids_to_migrate, + has_states_context_ids_to_migrate, ) from .run_history import RunHistory from .table_managers.event_types import EventTypeManager +from .table_managers.states_meta import StatesMetaManager from .tasks import ( AdjustLRUSizeTask, AdjustStatisticsTask, @@ -94,6 +99,7 @@ from .tasks import ( CommitTask, ContextIDMigrationTask, DatabaseLockTask, + EntityIDMigrationTask, EventTask, EventTypeIDMigrationTask, ImportStatisticsTask, @@ -215,6 +221,7 @@ class Recorder(threading.Thread): self._state_attributes_ids: LRU = LRU(STATE_ATTRIBUTES_ID_CACHE_SIZE) self._event_data_ids: LRU = LRU(EVENT_DATA_ID_CACHE_SIZE) self.event_type_manager = EventTypeManager() + self.states_meta_manager = StatesMetaManager() self._pending_state_attributes: dict[str, StateAttributes] = {} self._pending_event_data: dict[str, EventData] = {} self._pending_expunge: list[States] = [] @@ -652,7 +659,7 @@ class Recorder(threading.Thread): # If the migrate is live or the schema is valid, we need to # wait for startup to complete. If its not live, we need to continue # on. - self.hass.add_job(self.async_set_db_ready) + self._activate_and_set_db_ready() # We wait to start a live migration until startup has finished # since it can be cpu intensive and we do not want it to compete @@ -663,7 +670,7 @@ class Recorder(threading.Thread): # Make sure we cleanly close the run if # we restart before startup finishes self._shutdown() - self.hass.add_job(self.async_set_db_ready) + self._activate_and_set_db_ready() return if not schema_status.valid: @@ -681,11 +688,11 @@ class Recorder(threading.Thread): "Database Migration Failed", "recorder_database_migration", ) - self.hass.add_job(self.async_set_db_ready) + self._activate_and_set_db_ready() self._shutdown() return - self.hass.add_job(self.async_set_db_ready) + self._activate_and_set_db_ready() # Catch up with missed statistics with session_scope(session=self.get_session()) as session: @@ -694,26 +701,44 @@ class Recorder(threading.Thread): _LOGGER.debug("Recorder processing the queue") self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) - self._activate_table_managers_or_migrate() self._run_event_loop() self._shutdown() - def _activate_table_managers_or_migrate(self) -> None: - """Activate the table managers or schedule migrations.""" - # Currently we always check if context ids need to be migrated - # since there are multiple tables. This could be optimized - # to check both the states and events table to see if there - # are any missing and avoid inserting the task but it currently - # is not needed since there is no dependent code branching - # on the result of the migration. - self.queue_task(ContextIDMigrationTask()) + def _activate_and_set_db_ready(self) -> None: + """Activate the table managers or schedule migrations and mark the db as ready.""" with session_scope(session=self.get_session()) as session: - if session.execute(has_event_type_to_migrate()).scalar(): + if ( + self.schema_version < 36 + or session.execute(has_events_context_ids_to_migrate()).scalar() + or session.execute(has_states_context_ids_to_migrate()).scalar() + ): + self.queue_task(ContextIDMigrationTask()) + + if ( + self.schema_version < 37 + or session.execute(has_event_type_to_migrate()).scalar() + ): self.queue_task(EventTypeIDMigrationTask()) else: - _LOGGER.debug("Activating event type manager as all data is migrated") + _LOGGER.debug("Activating event_types manager as all data is migrated") self.event_type_manager.active = True + if ( + self.schema_version < 38 + or session.execute(has_entity_ids_to_migrate()).scalar() + ): + self.queue_task(EntityIDMigrationTask()) + else: + _LOGGER.debug("Activating states_meta manager as all data is migrated") + self.states_meta_manager.active = True + + # We must only set the db ready after we have set the table managers + # to active if there is no data to migrate. + # + # This ensures that the history queries will use the new tables + # and not the old ones as soon as the API is available. + self.hass.add_job(self.async_set_db_ready) + def _run_event_loop(self) -> None: """Run the event loop for the recorder.""" # Use a session for the event read loop @@ -750,6 +775,7 @@ class Recorder(threading.Thread): self._pre_process_state_change_events(state_change_events) self._pre_process_non_state_change_events(non_state_change_events) self.event_type_manager.load(non_state_change_events, self.event_session) + self.states_meta_manager.load(state_change_events, self.event_session) def _pre_process_state_change_events(self, events: list[Event]) -> None: """Load startup state attributes from the database. @@ -1033,13 +1059,26 @@ class Recorder(threading.Thread): def _process_state_changed_event_into_session(self, event: Event) -> None: """Process a state_changed event into the session.""" - assert self.event_session is not None dbstate = States.from_event(event) - if not ( + if (entity_id := dbstate.entity_id) is None or not ( shared_attrs_bytes := self._serialize_state_attributes_from_event(event) ): return + assert self.event_session is not None + event_session = self.event_session + # Map the entity_id to the StatesMeta table + states_meta_manager = self.states_meta_manager + if pending_states_meta := states_meta_manager.get_pending(entity_id): + dbstate.states_meta_rel = pending_states_meta + elif metadata_id := states_meta_manager.get(entity_id, event_session): + dbstate.metadata_id = metadata_id + else: + states_meta = StatesMeta(entity_id=entity_id) + states_meta_manager.add_pending(states_meta) + event_session.add(states_meta) + dbstate.states_meta_rel = states_meta + shared_attrs = shared_attrs_bytes.decode("utf-8") dbstate.attributes = None # Matching attributes found in the pending commit @@ -1063,16 +1102,20 @@ class Recorder(threading.Thread): self._pending_state_attributes[shared_attrs] = dbstate_attributes self.event_session.add(dbstate_attributes) - if old_state := self._old_states.pop(dbstate.entity_id, None): + if old_state := self._old_states.pop(entity_id, None): if old_state.state_id: dbstate.old_state_id = old_state.state_id else: dbstate.old_state = old_state if event.data.get("new_state"): - self._old_states[dbstate.entity_id] = dbstate + self._old_states[entity_id] = dbstate self._pending_expunge.append(dbstate) else: dbstate.state = None + + if states_meta_manager.active: + dbstate.entity_id = None + self.event_session.add(dbstate) def _handle_database_error(self, err: Exception) -> bool: @@ -1138,6 +1181,7 @@ class Recorder(threading.Thread): self._event_data_ids[event_data.shared_data] = event_data.data_id self._pending_event_data = {} self.event_type_manager.post_commit_pending() + self.states_meta_manager.post_commit_pending() # Expire is an expensive operation (frequently more expensive # than the flush and commit itself) so we only @@ -1165,6 +1209,7 @@ class Recorder(threading.Thread): self._pending_state_attributes.clear() self._pending_event_data.clear() self.event_type_manager.reset() + self.states_meta_manager.reset() if not self.event_session: return @@ -1199,6 +1244,14 @@ class Recorder(threading.Thread): """Migrate event type ids if needed.""" return migration.migrate_event_type_ids(self) + def _migrate_entity_ids(self) -> bool: + """Migrate entity_ids if needed.""" + return migration.migrate_entity_ids(self) + + def _post_migrate_entity_ids(self) -> bool: + """Post migrate entity_ids if needed.""" + return migration.post_migrate_entity_ids(self) + def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 9499e9d4e31e..7aecf2a57ca4 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 37 +SCHEMA_VERSION = 38 _LOGGER = logging.getLogger(__name__) @@ -77,6 +77,7 @@ TABLE_EVENT_DATA = "event_data" TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" +TABLE_STATES_META = "states_meta" TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" @@ -97,6 +98,7 @@ ALL_TABLES = [ TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, + TABLE_STATES_META, TABLE_STATISTICS, TABLE_STATISTICS_META, TABLE_STATISTICS_RUNS, @@ -111,7 +113,7 @@ TABLES_TO_CHECK = [ ] LAST_UPDATED_INDEX_TS = "ix_states_last_updated_ts" -ENTITY_ID_LAST_UPDATED_INDEX_TS = "ix_states_entity_id_last_updated_ts" +METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts" EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" CONTEXT_ID_BIN_MAX_LENGTH = 16 @@ -363,7 +365,7 @@ class States(Base): __table_args__ = ( # Used for fetching the state of entities at a specific time # (get_states in history.py) - Index(ENTITY_ID_LAST_UPDATED_INDEX_TS, "entity_id", "last_updated_ts"), + Index(METADATA_ID_LAST_UPDATED_INDEX_TS, "metadata_id", "last_updated_ts"), Index( STATES_CONTEXT_ID_BIN_INDEX, "context_id_bin", @@ -374,7 +376,9 @@ class States(Base): ) __tablename__ = TABLE_STATES state_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - entity_id: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_ENTITY_ID)) + entity_id: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_STATE_ENTITY_ID) + ) # no longer used for new rows state: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_STATE)) attributes: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") @@ -421,6 +425,10 @@ class States(Base): context_parent_id_bin: Mapped[bytes | None] = mapped_column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) + metadata_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("states_meta.metadata_id"), index=True + ) + states_meta_rel: Mapped[StatesMeta | None] = relationship("StatesMeta") def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -583,6 +591,23 @@ class StateAttributes(Base): return {} +class StatesMeta(Base): + """Metadata for states.""" + + __table_args__ = (_DEFAULT_TABLE_ARGS,) + __tablename__ = TABLE_STATES_META + metadata_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) + entity_id: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_ENTITY_ID)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class StatisticsBase: """Statistics base class.""" diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 90f7d8c0a064..63eed2d14540 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable, Collection, Iterable -import json from typing import Any from sqlalchemy import Column, Text, cast, not_, or_ @@ -10,13 +9,14 @@ from sqlalchemy.sql.elements import ColumnElement from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS +from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType -from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States +from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States, StatesMeta DOMAIN = "history" HISTORY_FILTERS = "history_filters" -JSON_NULL = json.dumps(None) +JSON_NULL = json_dumps(None) GLOB_TO_SQL_CHARS = { ord("*"): "%", @@ -194,7 +194,10 @@ class Filters: return i_entities def states_entity_filter(self) -> ColumnElement | None: - """Generate the entity filter query.""" + """Generate the States.entity_id filter query. + + This is no longer used except by the legacy queries. + """ def _encoder(data: Any) -> Any: """Nothing to encode for states since there is no json.""" @@ -203,9 +206,19 @@ class Filters: # The type annotation should be improved so the type ignore can be removed return self._generate_filter_for_columns((States.entity_id,), _encoder) # type: ignore[arg-type] + def states_metadata_entity_filter(self) -> ColumnElement | None: + """Generate the StatesMeta.entity_id filter query.""" + + def _encoder(data: Any) -> Any: + """Nothing to encode for states since there is no json.""" + return data + + # The type annotation should be improved so the type ignore can be removed + return self._generate_filter_for_columns((StatesMeta.entity_id,), _encoder) # type: ignore[arg-type] + def events_entity_filter(self) -> ColumnElement: """Generate the entity filter query.""" - _encoder = json.dumps + _encoder = json_dumps return or_( # sqlalchemy's SQLite json implementation always # wraps everything with JSON_QUOTE so it resolves to 'null' diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py index 1b7b9065b762..7a569e70b156 100644 --- a/homeassistant/components/recorder/history/__init__.py +++ b/homeassistant/components/recorder/history/__init__.py @@ -1,13 +1,23 @@ """Provide pre-made queries on top of the recorder component.""" from __future__ import annotations +from collections.abc import MutableMapping +from datetime import datetime +from typing import Any + +from sqlalchemy.orm.session import Session + +from homeassistant.core import HomeAssistant, State + +from ... import recorder +from ..filters import Filters from .const import NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS -from .legacy import ( - get_full_significant_states_with_session, - get_last_state_changes, - get_significant_states, - get_significant_states_with_session, - state_changes_during_period, +from .modern import ( + get_full_significant_states_with_session as _modern_get_full_significant_states_with_session, + get_last_state_changes as _modern_get_last_state_changes, + get_significant_states as _modern_get_significant_states, + get_significant_states_with_session as _modern_get_significant_states_with_session, + state_changes_during_period as _modern_state_changes_during_period, ) # These are the APIs of this package @@ -20,3 +30,154 @@ __all__ = [ "get_significant_states_with_session", "state_changes_during_period", ] + + +def get_full_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + no_attributes: bool = False, +) -> MutableMapping[str, list[State]]: + """Return a dict of significant states during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_full_significant_states_with_session as _legacy_get_full_significant_states_with_session, + ) + + _target = _legacy_get_full_significant_states_with_session + else: + _target = _modern_get_full_significant_states_with_session + return _target( + hass, + session, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + no_attributes, + ) + + +def get_last_state_changes( + hass: HomeAssistant, number_of_states: int, entity_id: str +) -> MutableMapping[str, list[State]]: + """Return the last number_of_states.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_last_state_changes as _legacy_get_last_state_changes, + ) + + _target = _legacy_get_last_state_changes + else: + _target = _modern_get_last_state_changes + return _target(hass, number_of_states, entity_id) + + +def get_significant_states( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Return a dict of significant states during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_significant_states as _legacy_get_significant_states, + ) + + _target = _legacy_get_significant_states + else: + _target = _modern_get_significant_states + return _target( + hass, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def get_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Return a dict of significant states during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + get_significant_states_with_session as _legacy_get_significant_states_with_session, + ) + + _target = _legacy_get_significant_states_with_session + else: + _target = _modern_get_significant_states_with_session + return _target( + hass, + session, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def state_changes_during_period( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_id: str | None = None, + no_attributes: bool = False, + descending: bool = False, + limit: int | None = None, + include_start_time_state: bool = True, +) -> MutableMapping[str, list[State]]: + """Return a list of states that changed during a time period.""" + if not recorder.get_instance(hass).states_meta_manager.active: + from .legacy import ( # pylint: disable=import-outside-toplevel + state_changes_during_period as _legacy_state_changes_during_period, + ) + + _target = _legacy_state_changes_during_period + else: + _target = _modern_state_changes_during_period + return _target( + hass, + start_time, + end_time, + entity_id, + no_attributes, + descending, + limit, + include_start_time_state, + ) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py new file mode 100644 index 000000000000..dce3b51edf5d --- /dev/null +++ b/homeassistant/components/recorder/history/modern.py @@ -0,0 +1,783 @@ +"""Provide pre-made queries on top of the recorder component.""" +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator, MutableMapping +from datetime import datetime +from itertools import groupby +import logging +from operator import itemgetter +from typing import Any, cast + +from sqlalchemy import Column, and_, func, lambda_stmt, or_, select +from sqlalchemy.engine.row import Row +from sqlalchemy.orm.properties import MappedColumn +from sqlalchemy.orm.query import Query +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.expression import literal +from sqlalchemy.sql.lambdas import StatementLambdaElement + +from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE +from homeassistant.core import HomeAssistant, State, split_entity_id +import homeassistant.util.dt as dt_util + +from ... import recorder +from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta +from ..filters import Filters +from ..models import ( + LazyState, + process_timestamp, + process_timestamp_to_utc_isoformat, + row_to_compressed_state, +) +from ..util import execute_stmt_lambda_element, session_scope +from .const import ( + IGNORE_DOMAINS_ENTITY_ID_LIKE, + LAST_CHANGED_KEY, + NEED_ATTRIBUTE_DOMAINS, + SIGNIFICANT_DOMAINS, + SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE, + STATE_KEY, +) + +_LOGGER = logging.getLogger(__name__) + + +_BASE_STATES = ( + States.metadata_id, + States.state, + States.last_changed_ts, + States.last_updated_ts, +) +_BASE_STATES_NO_LAST_CHANGED = ( # type: ignore[var-annotated] + States.metadata_id, + States.state, + literal(value=None).label("last_changed_ts"), + States.last_updated_ts, +) +_QUERY_STATE_NO_ATTR = (*_BASE_STATES,) +_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED = (*_BASE_STATES_NO_LAST_CHANGED,) +_QUERY_STATES = ( + *_BASE_STATES, + # Remove States.attributes once all attributes are in StateAttributes.shared_attrs + States.attributes, + StateAttributes.shared_attrs, +) +_QUERY_STATES_NO_LAST_CHANGED = ( + *_BASE_STATES_NO_LAST_CHANGED, + # Remove States.attributes once all attributes are in StateAttributes.shared_attrs + States.attributes, + StateAttributes.shared_attrs, +) +_FIELD_MAP = { + cast(MappedColumn, field).name: idx + for idx, field in enumerate(_QUERY_STATE_NO_ATTR) +} + + +def _lambda_stmt_and_join_attributes( + no_attributes: bool, include_last_changed: bool = True +) -> tuple[StatementLambdaElement, bool]: + """Return the lambda_stmt and if StateAttributes should be joined. + + Because these are lambda_stmt the values inside the lambdas need + to be explicitly written out to avoid caching the wrong values. + """ + # If no_attributes was requested we do the query + # without the attributes fields and do not join the + # state_attributes table + if no_attributes: + if include_last_changed: + return ( + lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)), + False, + ) + return ( + lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), + False, + ) + + if include_last_changed: + return lambda_stmt(lambda: select(*_QUERY_STATES)), True + return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True + + +def get_significant_states( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Wrap get_significant_states_with_session with an sql session.""" + with session_scope(hass=hass) as session: + return get_significant_states_with_session( + hass, + session, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def _ignore_domains_filter(query: Query) -> Query: + """Add a filter to ignore domains we do not fetch history for.""" + return query.filter( + and_( + *[ + ~StatesMeta.entity_id.like(entity_domain) + for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE + ] + ) + ) + + +def _significant_states_stmt( + start_time: datetime, + end_time: datetime | None, + entity_ids: list[str] | None, + metadata_ids: list[int] | None, + filters: Filters | None, + significant_changes_only: bool, + no_attributes: bool, +) -> StatementLambdaElement: + """Query the database for significant state changes.""" + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=not significant_changes_only + ) + join_states_meta = False + if ( + entity_ids + and len(entity_ids) == 1 + and significant_changes_only + and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS + ): + stmt += lambda q: q.filter( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) + elif significant_changes_only: + stmt += lambda q: q.filter( + or_( + *[ + StatesMeta.entity_id.like(entity_domain) + for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE + ], + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ), + ) + ) + join_states_meta = True + + if metadata_ids: + stmt += lambda q: q.filter( + # https://github.com/python/mypy/issues/2608 + States.metadata_id.in_(metadata_ids) # type:ignore[arg-type] + ) + else: + stmt += _ignore_domains_filter + if filters and filters.has_config: + entity_filter = filters.states_metadata_entity_filter() + stmt = stmt.add_criteria( + lambda q: q.filter(entity_filter), track_on=[filters] + ) + join_states_meta = True + + start_time_ts = start_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts) + if end_time: + end_time_ts = end_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) + if join_states_meta: + stmt += lambda q: q.outerjoin( + StatesMeta, States.metadata_id == StatesMeta.metadata_id + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) + return stmt + + +def get_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Return states changes during UTC period start_time - end_time. + + entity_ids is an optional iterable of entities to include in the results. + + filters is an optional SQLAlchemy filter which will be applied to the database + queries unless entity_ids is given, in which case its ignored. + + Significant states are all states where there is a state change, + as well as all states from certain domains (for instance + thermostat so that we get current temperature in our graphs). + """ + metadata_ids: list[int] | None = None + entity_id_to_metadata_id: dict[str, int | None] | None = None + if entity_ids: + instance = recorder.get_instance(hass) + entity_id_to_metadata_id = instance.states_meta_manager.get_many( + entity_ids, session + ) + metadata_ids = [ + metadata_id + for metadata_id in entity_id_to_metadata_id.values() + if metadata_id is not None + ] + stmt = _significant_states_stmt( + start_time, + end_time, + entity_ids, + metadata_ids, + filters, + significant_changes_only, + no_attributes, + ) + states = execute_stmt_lambda_element( + session, stmt, None if entity_ids else start_time, end_time + ) + return _sorted_states_to_dict( + hass, + session, + states, + start_time, + entity_ids, + entity_id_to_metadata_id, + filters, + include_start_time_state, + minimal_response, + no_attributes, + compressed_state_format, + ) + + +def get_full_significant_states_with_session( + hass: HomeAssistant, + session: Session, + start_time: datetime, + end_time: datetime | None = None, + entity_ids: list[str] | None = None, + filters: Filters | None = None, + include_start_time_state: bool = True, + significant_changes_only: bool = True, + no_attributes: bool = False, +) -> MutableMapping[str, list[State]]: + """Variant of get_significant_states_with_session. + + Difference with get_significant_states_with_session is that it does not + return minimal responses. + """ + return cast( + MutableMapping[str, list[State]], + get_significant_states_with_session( + hass=hass, + session=session, + start_time=start_time, + end_time=end_time, + entity_ids=entity_ids, + filters=filters, + include_start_time_state=include_start_time_state, + significant_changes_only=significant_changes_only, + minimal_response=False, + no_attributes=no_attributes, + ), + ) + + +def _state_changed_during_period_stmt( + start_time: datetime, + end_time: datetime | None, + metadata_id: int | None, + no_attributes: bool, + descending: bool, + limit: int | None, +) -> StatementLambdaElement: + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=False + ) + start_time_ts = start_time.timestamp() + stmt += lambda q: q.filter( + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) + & (States.last_updated_ts > start_time_ts) + ) + if end_time: + end_time_ts = end_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) + if metadata_id: + stmt += lambda q: q.filter(States.metadata_id == metadata_id) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + if descending: + stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts.desc()) + else: + stmt += lambda q: q.order_by(States.metadata_id, States.last_updated_ts) + if limit: + stmt += lambda q: q.limit(limit) + return stmt + + +def state_changes_during_period( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_id: str | None = None, + no_attributes: bool = False, + descending: bool = False, + limit: int | None = None, + include_start_time_state: bool = True, +) -> MutableMapping[str, list[State]]: + """Return states changes during UTC period start_time - end_time.""" + entity_id = entity_id.lower() if entity_id is not None else None + entity_ids = [entity_id] if entity_id is not None else None + + with session_scope(hass=hass) as session: + metadata_id: int | None = None + entity_id_to_metadata_id = None + if entity_id: + instance = recorder.get_instance(hass) + metadata_id = instance.states_meta_manager.get(entity_id, session) + entity_id_to_metadata_id = {entity_id: metadata_id} + stmt = _state_changed_during_period_stmt( + start_time, + end_time, + metadata_id, + no_attributes, + descending, + limit, + ) + states = execute_stmt_lambda_element( + session, stmt, None if entity_id else start_time, end_time + ) + return cast( + MutableMapping[str, list[State]], + _sorted_states_to_dict( + hass, + session, + states, + start_time, + entity_ids, + entity_id_to_metadata_id, + include_start_time_state=include_start_time_state, + ), + ) + + +def _get_last_state_changes_stmt( + number_of_states: int, metadata_id: int +) -> StatementLambdaElement: + stmt, join_attributes = _lambda_stmt_and_join_attributes( + False, include_last_changed=False + ) + stmt += lambda q: q.where( + States.state_id + == ( + select(States.state_id) + .filter(States.metadata_id == metadata_id) + .order_by(States.last_updated_ts.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + + stmt += lambda q: q.order_by(States.state_id.desc()) + return stmt + + +def get_last_state_changes( + hass: HomeAssistant, number_of_states: int, entity_id: str +) -> MutableMapping[str, list[State]]: + """Return the last number_of_states.""" + entity_id_lower = entity_id.lower() + entity_ids = [entity_id_lower] + + with session_scope(hass=hass) as session: + instance = recorder.get_instance(hass) + if not (metadata_id := instance.states_meta_manager.get(entity_id, session)): + return {} + entity_id_to_metadata_id: dict[str, int | None] = {entity_id_lower: metadata_id} + stmt = _get_last_state_changes_stmt(number_of_states, metadata_id) + states = list(execute_stmt_lambda_element(session, stmt)) + return cast( + MutableMapping[str, list[State]], + _sorted_states_to_dict( + hass, + session, + reversed(states), + dt_util.utcnow(), + entity_ids, + entity_id_to_metadata_id, + include_start_time_state=False, + ), + ) + + +def _get_states_for_entities_stmt( + run_start: datetime, + utc_point_in_time: datetime, + metadata_ids: list[int], + no_attributes: bool, +) -> StatementLambdaElement: + """Baked query to get states for specific entities.""" + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=True + ) + # We got an include-list of entities, accelerate the query by filtering already + # in the inner query. + run_start_ts = process_timestamp(run_start).timestamp() + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += lambda q: q.join( + ( + most_recent_states_for_entities_by_date := ( + select( + States.metadata_id.label("max_metadata_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < utc_point_in_time_ts) + ) + .filter(States.metadata_id.in_(metadata_ids)) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id + == most_recent_states_for_entities_by_date.c.max_metadata_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + return stmt + + +def _get_states_for_all_stmt( + run_start: datetime, + utc_point_in_time: datetime, + filters: Filters | None, + no_attributes: bool, +) -> StatementLambdaElement: + """Baked query to get states for all entities.""" + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=True + ) + # We did not get an include-list of entities, query all states in the inner + # query, then filter out unwanted domains as well as applying the custom filter. + # This filtering can't be done in the inner query because the domain column is + # not indexed and we can't control what's in the custom filter. + run_start_ts = process_timestamp(run_start).timestamp() + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += lambda q: q.join( + ( + most_recent_states_by_date := ( + select( + States.metadata_id.label("max_metadata_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter( + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < utc_point_in_time_ts) + ) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id == most_recent_states_by_date.c.max_metadata_id, + States.last_updated_ts == most_recent_states_by_date.c.max_last_updated, + ), + ) + stmt += _ignore_domains_filter + if filters and filters.has_config: + entity_filter = filters.states_metadata_entity_filter() + stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) + stmt += lambda q: q.outerjoin( + StatesMeta, States.metadata_id == StatesMeta.metadata_id + ) + return stmt + + +def _get_rows_with_session( + hass: HomeAssistant, + session: Session, + utc_point_in_time: datetime, + entity_ids: list[str] | None = None, + entity_id_to_metadata_id: dict[str, int | None] | None = None, + run: RecorderRuns | None = None, + filters: Filters | None = None, + no_attributes: bool = False, +) -> Iterable[Row]: + """Return the states at a specific point in time.""" + if entity_ids and len(entity_ids) == 1: + if not entity_id_to_metadata_id or not ( + metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) + ): + return [] + return execute_stmt_lambda_element( + session, + _get_single_entity_states_stmt( + utc_point_in_time, metadata_id, no_attributes + ), + ) + + if run is None: + run = recorder.get_instance(hass).run_history.get(utc_point_in_time) + + if run is None or process_timestamp(run.start) > utc_point_in_time: + # History did not run before utc_point_in_time + return [] + + # We have more than one entity to look at so we need to do a query on states + # since the last recorder run started. + if entity_ids: + if not entity_id_to_metadata_id: + return [] + metadata_ids = [ + metadata_id + for metadata_id in entity_id_to_metadata_id.values() + if metadata_id is not None + ] + if not metadata_ids: + return [] + stmt = _get_states_for_entities_stmt( + run.start, utc_point_in_time, metadata_ids, no_attributes + ) + else: + stmt = _get_states_for_all_stmt( + run.start, utc_point_in_time, filters, no_attributes + ) + + return execute_stmt_lambda_element(session, stmt) + + +def _get_single_entity_states_stmt( + utc_point_in_time: datetime, + metadata_id: int, + no_attributes: bool = False, +) -> StatementLambdaElement: + # Use an entirely different (and extremely fast) query if we only + # have a single entity id + stmt, join_attributes = _lambda_stmt_and_join_attributes( + no_attributes, include_last_changed=True + ) + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += ( + lambda q: q.filter( + States.last_updated_ts < utc_point_in_time_ts, + States.metadata_id == metadata_id, + ) + .order_by(States.last_updated_ts.desc()) + .limit(1) + ) + if join_attributes: + stmt += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + return stmt + + +def _sorted_states_to_dict( + hass: HomeAssistant, + session: Session, + states: Iterable[Row], + start_time: datetime, + entity_ids: list[str] | None, + entity_id_to_metadata_id: dict[str, int | None] | None, + filters: Filters | None = None, + include_start_time_state: bool = True, + minimal_response: bool = False, + no_attributes: bool = False, + compressed_state_format: bool = False, +) -> MutableMapping[str, list[State | dict[str, Any]]]: + """Convert SQL results into JSON friendly data structure. + + This takes our state list and turns it into a JSON friendly data + structure {'entity_id': [list of states], 'entity_id2': [list of states]} + + States must be sorted by entity_id and last_updated + + We also need to go back and create a synthetic zero data point for + each list of states, otherwise our graphs won't start on the Y + axis correctly. + """ + field_map = _FIELD_MAP + state_class: Callable[ + [Row, dict[str, dict[str, Any]], datetime | None], State | dict[str, Any] + ] + if compressed_state_format: + state_class = row_to_compressed_state + attr_time = COMPRESSED_STATE_LAST_UPDATED + attr_state = COMPRESSED_STATE_STATE + else: + state_class = LazyState + attr_time = LAST_CHANGED_KEY + attr_state = STATE_KEY + + result: dict[str, list[State | dict[str, Any]]] = defaultdict(list) + metadata_id_to_entity_id: dict[int, str] = {} + metadata_id_idx = field_map["metadata_id"] + + # Set all entity IDs to empty lists in result set to maintain the order + if entity_ids is not None: + for ent_id in entity_ids: + result[ent_id] = [] + + if entity_id_to_metadata_id: + metadata_id_to_entity_id = { + v: k for k, v in entity_id_to_metadata_id.items() if v is not None + } + else: + metadata_id_to_entity_id = recorder.get_instance( + hass + ).states_meta_manager.get_metadata_id_to_entity_id(session) + + # Get the states at the start time + initial_states: dict[int, Row] = {} + if include_start_time_state: + initial_states = { + row[metadata_id_idx]: row + for row in _get_rows_with_session( + hass, + session, + start_time, + entity_ids, + entity_id_to_metadata_id, + filters=filters, + no_attributes=no_attributes, + ) + } + + if entity_ids and len(entity_ids) == 1: + if not entity_id_to_metadata_id or not ( + metadata_id := entity_id_to_metadata_id.get(entity_ids[0]) + ): + return {} + states_iter: Iterable[tuple[int, Iterator[Row]]] = ( + (metadata_id, iter(states)), + ) + else: + key_func = itemgetter(metadata_id_idx) + states_iter = groupby(states, key_func) + + # Append all changes to it + for metadata_id, group in states_iter: + attr_cache: dict[str, dict[str, Any]] = {} + prev_state: Column | str + if not (entity_id := metadata_id_to_entity_id.get(metadata_id)): + continue + ent_results = result[entity_id] + if row := initial_states.pop(metadata_id, None): + prev_state = row.state + ent_results.append(state_class(row, attr_cache, start_time, entity_id=entity_id)) # type: ignore[call-arg] + + if ( + not minimal_response + or split_entity_id(entity_id)[0] in NEED_ATTRIBUTE_DOMAINS + ): + ent_results.extend( + state_class(db_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + for db_state in group + ) + continue + + # With minimal response we only provide a native + # State for the first and last response. All the states + # in-between only provide the "state" and the + # "last_changed". + if not ent_results: + if (first_state := next(group, None)) is None: + continue + prev_state = first_state.state + ent_results.append( + state_class(first_state, attr_cache, None, entity_id=entity_id) # type: ignore[call-arg] + ) + + state_idx = field_map["state"] + + # + # minimal_response only makes sense with last_updated == last_updated + # + # We use last_updated for for last_changed since its the same + # + # With minimal response we do not care about attribute + # changes so we can filter out duplicate states + last_updated_ts_idx = field_map["last_updated_ts"] + if compressed_state_format: + for row in group: + if (state := row[state_idx]) != prev_state: + ent_results.append( + { + attr_state: state, + attr_time: row[last_updated_ts_idx], + } + ) + prev_state = state + + for row in group: + if (state := row[state_idx]) != prev_state: + ent_results.append( + { + attr_state: state, + attr_time: process_timestamp_to_utc_isoformat( + dt_util.utc_from_timestamp(row[last_updated_ts_idx]) + ), + } + ) + prev_state = state + + # If there are no states beyond the initial state, + # the state a was never popped from initial_states + for metadata_id, row in initial_states.items(): + if entity_id := metadata_id_to_entity_id.get(metadata_id): + result[entity_id].append( + state_class(row, {}, start_time, entity_id=entity_id) # type: ignore[call-arg] + ) + + # Filter out the empty lists if some states had 0 results. + return {key: val for key, val in result.items() if val} diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index e7a34f22fccd..392a829cb84b 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -38,6 +38,7 @@ from .db_schema import ( EventTypes, SchemaChanges, States, + StatesMeta, Statistics, StatisticsMeta, StatisticsRuns, @@ -45,6 +46,8 @@ from .db_schema import ( ) from .models import process_timestamp from .queries import ( + batch_cleanup_entity_ids, + find_entity_ids_to_migrate, find_event_type_to_migrate, find_events_context_ids_to_migrate, find_states_context_ids_to_migrate, @@ -68,6 +71,8 @@ if TYPE_CHECKING: LIVE_MIGRATION_MIN_SCHEMA_VERSION = 0 _EMPTY_CONTEXT_ID = b"\x00" * 16 +_EMPTY_ENTITY_ID = "missing.entity_id" +_EMPTY_EVENT_TYPE = "missing_event_type" _LOGGER = logging.getLogger(__name__) @@ -985,6 +990,10 @@ def _apply_update( # noqa: C901 _create_index(session_maker, "events", "ix_events_event_type_id") _drop_index(session_maker, "events", "ix_events_event_type_time_fired_ts") _create_index(session_maker, "events", "ix_events_event_type_id_time_fired_ts") + elif new_version == 38: + _add_columns(session_maker, "states", [f"metadata_id {big_int}"]) + _create_index(session_maker, "states", "ix_states_metadata_id") + _create_index(session_maker, "states", "ix_states_metadata_id_last_updated_ts") else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -1305,7 +1314,10 @@ def migrate_event_type_ids(instance: Recorder) -> bool: event_types = {event_type for _, event_type in events} event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - event_type + # We should never see see None for the event_Type in the events table + # but we need to be defensive so we don't fail the migration + # because of a bad event + _EMPTY_EVENT_TYPE if event_type is None else event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1318,7 +1330,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: for db_event_type in missing_db_event_types: # We cannot add the assigned ids to the event_type_manager # because the commit could get rolled back - assert db_event_type.event_type is not None + assert ( + db_event_type.event_type is not None + ), "event_type should never be None" event_type_to_id[ db_event_type.event_type ] = db_event_type.event_type_id @@ -1346,6 +1360,89 @@ def migrate_event_type_ids(instance: Recorder) -> bool: return is_done +def migrate_entity_ids(instance: Recorder) -> bool: + """Migrate entity_ids to states_meta. + + We do this in two steps because we need the history queries to work + while we are migrating. + + 1. Link the states to the states_meta table + 2. Remove the entity_id column from the states table (in post_migrate_entity_ids) + """ + _LOGGER.debug("Migrating entity_ids") + states_meta_manager = instance.states_meta_manager + with session_scope(session=instance.get_session()) as session: + if states := session.execute(find_entity_ids_to_migrate()).all(): + entity_ids = {entity_id for _, entity_id in states} + entity_id_to_metadata_id = states_meta_manager.get_many(entity_ids, session) + if missing_entity_ids := { + # We should never see _EMPTY_ENTITY_ID in the states table + # but we need to be defensive so we don't fail the migration + # because of a bad state + _EMPTY_ENTITY_ID if entity_id is None else entity_id + for entity_id, metadata_id in entity_id_to_metadata_id.items() + if metadata_id is None + }: + missing_states_metadata = [ + StatesMeta(entity_id=entity_id) for entity_id in missing_entity_ids + ] + session.add_all(missing_states_metadata) + session.flush() # Assign ids + for db_states_metadata in missing_states_metadata: + # We cannot add the assigned ids to the event_type_manager + # because the commit could get rolled back + assert ( + db_states_metadata.entity_id is not None + ), "entity_id should never be None" + entity_id_to_metadata_id[ + db_states_metadata.entity_id + ] = db_states_metadata.metadata_id + + session.execute( + update(States), + [ + { + "state_id": state_id, + # We cannot set "entity_id": None yet since + # the history queries still need to work while the + # migration is in progress and we will do this in + # post_migrate_entity_ids + "metadata_id": entity_id_to_metadata_id[entity_id], + } + for state_id, entity_id in states + ], + ) + + # If there is more work to do return False + # so that we can be called again + is_done = not states + + _LOGGER.debug("Migrating entity_ids done=%s", is_done) + return is_done + + +def post_migrate_entity_ids(instance: Recorder) -> bool: + """Remove old entity_id strings from states. + + We cannot do this in migrate_entity_ids since the history queries + still need to work while the migration is in progress. + """ + session_maker = instance.get_session + _LOGGER.debug("Cleanup legacy entity_ids") + with session_scope(session=session_maker()) as session: + cursor_result = session.connection().execute(batch_cleanup_entity_ids()) + is_done = not cursor_result or cursor_result.rowcount == 0 + # If there is more work to do return False + # so that we can be called again + + if is_done: + # Drop the old indexes since they are no longer needed + _drop_index(session_maker, "states", "ix_states_entity_id_last_updated_ts") + + _LOGGER.debug("Cleanup legacy entity_ids done=%s", is_done) + return is_done + + def _initialize_database(session: Session) -> bool: """Initialize a new database. diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 12983a3e6886..c70e43426356 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -41,10 +41,11 @@ class LazyState(State): row: Row, attr_cache: dict[str, dict[str, Any]], start_time: datetime | None, + entity_id: str | None = None, ) -> None: """Init the lazy state.""" self._row = row - self.entity_id: str = self._row.entity_id + self.entity_id = entity_id or self._row.entity_id self.state = self._row.state or "" self._attributes: dict[str, Any] | None = None self._last_updated_ts: float | None = self._row.last_updated_ts or ( @@ -127,6 +128,7 @@ def row_to_compressed_state( row: Row, attr_cache: dict[str, dict[str, Any]], start_time: datetime | None, + entity_id: str | None = None, ) -> dict[str, Any]: """Convert a database row to a compressed state schema 31 and later.""" comp_state = { diff --git a/homeassistant/components/recorder/models/state_attributes.py b/homeassistant/components/recorder/models/state_attributes.py index 738684c02153..3ed109afa071 100644 --- a/homeassistant/components/recorder/models/state_attributes.py +++ b/homeassistant/components/recorder/models/state_attributes.py @@ -17,11 +17,13 @@ def decode_attributes_from_row( row: Row, attr_cache: dict[str, dict[str, Any]] ) -> dict[str, Any]: """Decode attributes from a database row.""" - source: str = row.shared_attrs or row.attributes - if (attributes := attr_cache.get(source)) is not None: - return attributes + source: str | None = getattr(row, "shared_attrs", None) or getattr( + row, "attributes", None + ) if not source or source == EMPTY_JSON_OBJECT: return {} + if (attributes := attr_cache.get(source)) is not None: + return attributes try: attr_cache[source] = attributes = json_loads_object(source) except ValueError: diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 368a6ccdf1c6..bb97448f1496 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -15,7 +15,7 @@ from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.util.dt as dt_util from .const import SQLITE_MAX_BIND_VARS -from .db_schema import Events, StateAttributes, States +from .db_schema import Events, StateAttributes, States, StatesMeta from .models import DatabaseEngine from .queries import ( attributes_ids_exist_in_states, @@ -27,10 +27,12 @@ from .queries import ( delete_event_types_rows, delete_recorder_runs_rows, delete_states_attributes_rows, + delete_states_meta_rows, delete_states_rows, delete_statistics_runs_rows, delete_statistics_short_term_rows, disconnect_states_rows, + find_entity_ids_to_purge, find_event_types_to_purge, find_events_to_purge, find_latest_statistics_runs_run_id, @@ -116,6 +118,9 @@ def purge_old_data( if instance.event_type_manager.active: _purge_old_event_types(instance, session) + if instance.states_meta_manager.active: + _purge_old_entity_ids(instance, session) + _purge_old_recorder_runs(instance, session, purge_before) if repack: repack_database(instance) @@ -590,6 +595,25 @@ def _purge_old_event_types(instance: Recorder, session: Session) -> None: instance.event_type_manager.evict_purged(purge_event_types) +def _purge_old_entity_ids(instance: Recorder, session: Session) -> None: + """Purge all old entity_ids.""" + # entity_ids are small, no need to batch run it + purge_entity_ids = set() + states_metadata_ids = set() + for metadata_id, entity_id in session.execute(find_entity_ids_to_purge()): + purge_entity_ids.add(entity_id) + states_metadata_ids.add(metadata_id) + + if not states_metadata_ids: + return + + deleted_rows = session.execute(delete_states_meta_rows(states_metadata_ids)) + _LOGGER.debug("Deleted %s states meta", deleted_rows) + + # Evict any entries in the event_type cache referring to a purged state + instance.states_meta_manager.evict_purged(purge_entity_ids) + + def _purge_filtered_data(instance: Recorder, session: Session) -> bool: """Remove filtered states and events that shouldn't be in the database.""" _LOGGER.debug("Cleanup filtered data") @@ -597,13 +621,18 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: assert database_engine is not None # Check if excluded entity_ids are in database - excluded_entity_ids: list[str] = [ - entity_id - for (entity_id,) in session.query(distinct(States.entity_id)).all() - if not instance.entity_filter(entity_id) + entity_filter = instance.entity_filter + excluded_metadata_ids: list[str] = [ + metadata_id + for (metadata_id, entity_id) in session.query( + StatesMeta.metadata_id, StatesMeta.entity_id + ).all() + if not entity_filter(entity_id) ] - if len(excluded_entity_ids) > 0: - _purge_filtered_states(instance, session, excluded_entity_ids, database_engine) + if len(excluded_metadata_ids) > 0: + _purge_filtered_states( + instance, session, excluded_metadata_ids, database_engine + ) return False # Check if excluded event_types are in database @@ -622,7 +651,7 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: def _purge_filtered_states( instance: Recorder, session: Session, - excluded_entity_ids: list[str], + excluded_metadata_ids: list[str], database_engine: DatabaseEngine, ) -> None: """Remove filtered states and linked events.""" @@ -632,7 +661,7 @@ def _purge_filtered_states( state_ids, attributes_ids, event_ids = zip( *( session.query(States.state_id, States.attributes_id, States.event_id) - .filter(States.entity_id.in_(excluded_entity_ids)) + .filter(States.metadata_id.in_(excluded_metadata_ids)) .limit(SQLITE_MAX_BIND_VARS) .all() ) @@ -687,17 +716,19 @@ def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) database_engine = instance.database_engine assert database_engine is not None with session_scope(session=instance.get_session()) as session: - selected_entity_ids: list[str] = [ - entity_id - for (entity_id,) in session.query(distinct(States.entity_id)).all() + selected_metadata_ids: list[str] = [ + metadata_id + for (metadata_id, entity_id) in session.query( + StatesMeta.metadata_id, StatesMeta.entity_id + ).all() if entity_filter(entity_id) ] - _LOGGER.debug("Purging entity data for %s", selected_entity_ids) - if len(selected_entity_ids) > 0: + _LOGGER.debug("Purging entity data for %s", selected_metadata_ids) + if len(selected_metadata_ids) > 0: # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states # or events record. _purge_filtered_states( - instance, session, selected_entity_ids, database_engine + instance, session, selected_metadata_ids, database_engine ) _LOGGER.debug("Purging entity data hasn't fully completed yet") return False diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index d0672e615817..737faf2f7eca 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -16,6 +16,7 @@ from .db_schema import ( RecorderRuns, StateAttributes, States, + StatesMeta, StatisticsRuns, StatisticsShortTerm, ) @@ -59,6 +60,20 @@ def find_event_type_ids(event_types: Iterable[str]) -> StatementLambdaElement: ) +def find_all_states_metadata_ids() -> StatementLambdaElement: + """Find all metadata_ids and entity_ids.""" + return lambda_stmt(lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id)) + + +def find_states_metadata_ids(entity_ids: Iterable[str]) -> StatementLambdaElement: + """Find metadata_ids by entity_ids.""" + return lambda_stmt( + lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id).filter( + StatesMeta.entity_id.in_(entity_ids) + ) + ) + + def find_shared_attributes_id( data_hash: int, shared_attrs: str ) -> StatementLambdaElement: @@ -716,6 +731,54 @@ def find_event_type_to_migrate() -> StatementLambdaElement: ) +def find_entity_ids_to_migrate() -> StatementLambdaElement: + """Find entity_id to migrate.""" + return lambda_stmt( + lambda: select( + States.state_id, + States.entity_id, + ) + .filter(States.metadata_id.is_(None)) + .limit(SQLITE_MAX_BIND_VARS) + ) + + +def batch_cleanup_entity_ids() -> StatementLambdaElement: + """Find entity_id to cleanup.""" + # Self join because This version of MariaDB doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery' + return lambda_stmt( + lambda: update(States) + .where( + States.state_id.in_( + select(States.state_id).join( + states_with_entity_ids := select( + States.state_id.label("state_id_with_entity_id") + ) + .filter(States.entity_id.is_not(None)) + .limit(5000) + .subquery(), + States.state_id == states_with_entity_ids.c.state_id_with_entity_id, + ) + ) + ) + .values(entity_id=None) + ) + + +def has_events_context_ids_to_migrate() -> StatementLambdaElement: + """Check if there are events context ids to migrate.""" + return lambda_stmt( + lambda: select(Events.event_id).filter(Events.context_id_bin.is_(None)).limit(1) + ) + + +def has_states_context_ids_to_migrate() -> StatementLambdaElement: + """Check if there are states context ids to migrate.""" + return lambda_stmt( + lambda: select(States.state_id).filter(States.context_id_bin.is_(None)).limit(1) + ) + + def has_event_type_to_migrate() -> StatementLambdaElement: """Check if there are event_types to migrate.""" return lambda_stmt( @@ -723,6 +786,13 @@ def has_event_type_to_migrate() -> StatementLambdaElement: ) +def has_entity_ids_to_migrate() -> StatementLambdaElement: + """Check if there are entity_id to migrate.""" + return lambda_stmt( + lambda: select(States.state_id).filter(States.metadata_id.is_(None)).limit(1) + ) + + def find_states_context_ids_to_migrate() -> StatementLambdaElement: """Find events context_ids to migrate.""" return lambda_stmt( @@ -754,6 +824,23 @@ def find_event_types_to_purge() -> StatementLambdaElement: ) +def find_entity_ids_to_purge() -> StatementLambdaElement: + """Find entity_ids to purge.""" + return lambda_stmt( + lambda: select(StatesMeta.metadata_id, StatesMeta.entity_id).where( + StatesMeta.metadata_id.not_in( + select(StatesMeta.metadata_id).join( + used_states_metadata_id := select( + distinct(States.metadata_id).label("used_states_metadata_id") + ).subquery(), + StatesMeta.metadata_id + == used_states_metadata_id.c.used_states_metadata_id, + ) + ) + ) + ) + + def delete_event_types_rows(event_type_ids: Iterable[int]) -> StatementLambdaElement: """Delete EventTypes rows.""" return lambda_stmt( @@ -761,3 +848,12 @@ def delete_event_types_rows(event_type_ids: Iterable[int]) -> StatementLambdaEle .where(EventTypes.event_type_id.in_(event_type_ids)) .execution_options(synchronize_session=False) ) + + +def delete_states_meta_rows(metadata_ids: Iterable[int]) -> StatementLambdaElement: + """Delete StatesMeta rows.""" + return lambda_stmt( + lambda: delete(StatesMeta) + .where(StatesMeta.metadata_id.in_(metadata_ids)) + .execution_options(synchronize_session=False) + ) diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py new file mode 100644 index 000000000000..8650df7c8b24 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -0,0 +1,94 @@ +"""Support managing StatesMeta.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import cast + +from lru import LRU # pylint: disable=no-name-in-module +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event + +from ..db_schema import StatesMeta +from ..queries import find_all_states_metadata_ids, find_states_metadata_ids + +CACHE_SIZE = 8192 + + +class StatesMetaManager: + """Manage the StatesMeta table.""" + + def __init__(self) -> None: + """Initialize the states meta manager.""" + self._id_map: dict[str, int] = LRU(CACHE_SIZE) + self._pending: dict[str, StatesMeta] = {} + self.active = False + + def load(self, events: list[Event], session: Session) -> None: + """Load the entity_id to metadata_id mapping into memory.""" + self.get_many( + ( + event.data["new_state"].entity_id + for event in events + if event.data.get("new_state") is not None + ), + session, + ) + + def get(self, entity_id: str, session: Session) -> int | None: + """Resolve entity_id to the metadata_id.""" + return self.get_many((entity_id,), session)[entity_id] + + def get_metadata_id_to_entity_id(self, session: Session) -> dict[int, str]: + """Resolve all entity_ids to metadata_ids.""" + with session.no_autoflush: + return dict(tuple(session.execute(find_all_states_metadata_ids()))) # type: ignore[arg-type] + + def get_many( + self, entity_ids: Iterable[str], session: Session + ) -> dict[str, int | None]: + """Resolve entity_id to metadata_id.""" + results: dict[str, int | None] = {} + missing: list[str] = [] + for entity_id in entity_ids: + if (metadata_id := self._id_map.get(entity_id)) is None: + missing.append(entity_id) + + results[entity_id] = metadata_id + + if not missing: + return results + + with session.no_autoflush: + for metadata_id, entity_id in session.execute( + find_states_metadata_ids(missing) + ): + results[entity_id] = self._id_map[entity_id] = cast(int, metadata_id) + + return results + + def get_pending(self, entity_id: str) -> StatesMeta | None: + """Get pending StatesMeta that have not be assigned ids yet.""" + return self._pending.get(entity_id) + + def add_pending(self, db_states_meta: StatesMeta) -> None: + """Add a pending StatesMeta that will be committed at the next interval.""" + assert db_states_meta.entity_id is not None + entity_id: str = db_states_meta.entity_id + self._pending[entity_id] = db_states_meta + + def post_commit_pending(self) -> None: + """Call after commit to load the metadata_ids of the new StatesMeta into the LRU.""" + for entity_id, db_states_meta in self._pending.items(): + self._id_map[entity_id] = db_states_meta.metadata_id + self._pending.clear() + + def reset(self) -> None: + """Reset the states meta manager after the database has been reset or changed.""" + self._id_map.clear() + self._pending.clear() + + def evict_purged(self, entity_ids: Iterable[str]) -> None: + """Evict purged event_types from the cache when they are no longer used.""" + for entity_id in entity_ids: + self._id_map.pop(entity_id, None) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 81a105742b4d..0b99ca742b2f 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -372,3 +372,39 @@ class EventTypeIDMigrationTask(RecorderTask): if not instance._migrate_event_type_ids(): # pylint: disable=[protected-access] # Schedule a new migration task if this one didn't finish instance.queue_task(EventTypeIDMigrationTask()) + + +@dataclass +class EntityIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate entity_ids to StatesMeta.""" + + commit_before = True + # We have to commit before to make sure there are + # no new pending states_meta about to be added to + # the db since this happens live + + def run(self, instance: Recorder) -> None: + """Run entity_id migration task.""" + if not instance._migrate_entity_ids(): # pylint: disable=[protected-access] + # Schedule a new migration task if this one didn't finish + instance.queue_task(EntityIDMigrationTask()) + else: + # The migration has finished, now we start the post migration + # to remove the old entity_id data from the states table + # at this point we can also start using the StatesMeta table + # so we set active to True + instance.states_meta_manager.active = True + instance.queue_task(EntityIDPostMigrationTask()) + + +@dataclass +class EntityIDPostMigrationTask(RecorderTask): + """An object to insert into the recorder queue to cleanup after entity_ids migration.""" + + def run(self, instance: Recorder) -> None: + """Run entity_id post migration task.""" + if ( + not instance._post_migrate_entity_ids() # pylint: disable=[protected-access] + ): + # Schedule a new migration task if this one didn't finish + instance.queue_task(EntityIDPostMigrationTask()) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 7c1b7a5e97b0..a300f58b96af 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -69,7 +69,9 @@ def db_schema_30(): with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( + core, "EventTypes", old_db_schema.EventTypes + ), patch.object( core, "EventData", old_db_schema.EventData ), patch.object( core, "States", old_db_schema.States @@ -83,26 +85,34 @@ def db_schema_30(): yield -@pytest.mark.usefixtures("hass_history") +@pytest.fixture +def legacy_hass_history(hass_history): + """Home Assistant fixture to use legacy history recording.""" + instance = recorder.get_instance(hass_history) + with patch.object(instance.states_meta_manager, "active", False): + yield hass_history + + +@pytest.mark.usefixtures("legacy_hass_history") def test_setup() -> None: """Test setup method of history.""" # Verification occurs in the fixture -def test_get_significant_states(hass_history) -> None: +def test_get_significant_states(legacy_hass_history) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) hist = get_significant_states(hass, zero, four, filters=history.Filters()) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_minimal_response(hass_history) -> None: +def test_get_significant_states_minimal_response(legacy_hass_history) -> None: """Test that only significant states are returned. When minimal responses is set only the first and @@ -112,7 +122,7 @@ def test_get_significant_states_minimal_response(hass_history) -> None: includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) hist = get_significant_states( hass, zero, four, filters=history.Filters(), minimal_response=True @@ -168,14 +178,14 @@ def test_get_significant_states_minimal_response(hass_history) -> None: ) -def test_get_significant_states_with_initial(hass_history) -> None: +def test_get_significant_states_with_initial(legacy_hass_history) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) one = zero + timedelta(seconds=1) one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) @@ -198,14 +208,14 @@ def test_get_significant_states_with_initial(hass_history) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_without_initial(hass_history) -> None: +def test_get_significant_states_without_initial(legacy_hass_history) -> None: """Test that only significant states are returned. We should get back every thermostat change that includes an attribute change, but only the state updates for media player (attribute changes are not significant and not returned). """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) one = zero + timedelta(seconds=1) one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) @@ -233,22 +243,25 @@ def test_get_significant_states_without_initial(hass_history) -> None: def test_get_significant_states_entity_id(hass_history) -> None: """Test that only significant states are returned for one entity.""" hass = hass_history - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - hist = get_significant_states( - hass, zero, four, ["media_player.test"], filters=history.Filters() - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + del states["media_player.test2"] + del states["media_player.test3"] + del states["thermostat.test"] + del states["thermostat.test2"] + del states["script.can_cancel_this_one"] + + hist = get_significant_states( + hass, zero, four, ["media_player.test"], filters=history.Filters() + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_multiple_entity_ids(hass_history) -> None: +def test_get_significant_states_multiple_entity_ids(legacy_hass_history) -> None: """Test that only significant states are returned for one entity.""" - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test2"] del states["media_player.test3"] @@ -265,13 +278,13 @@ def test_get_significant_states_multiple_entity_ids(hass_history) -> None: assert_dict_of_states_equal_without_context_and_last_changed(states, hist) -def test_get_significant_states_exclude_domain(hass_history) -> None: +def test_get_significant_states_exclude_domain(legacy_hass_history) -> None: """Test if significant states are returned when excluding domains. We should get back every thermostat change that includes an attribute change, but no media player changes. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] del states["media_player.test2"] @@ -286,13 +299,13 @@ def test_get_significant_states_exclude_domain(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_exclude_entity(hass_history) -> None: +def test_get_significant_states_exclude_entity(legacy_hass_history) -> None: """Test if significant states are returned when excluding entities. We should get back every thermostat and script changes, but no media player changes. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] @@ -305,12 +318,12 @@ def test_get_significant_states_exclude_entity(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_exclude(hass_history) -> None: +def test_get_significant_states_exclude(legacy_hass_history) -> None: """Test significant states when excluding entities and domains. We should not get back every thermostat and media player test changes. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] del states["thermostat.test"] @@ -330,12 +343,12 @@ def test_get_significant_states_exclude(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_exclude_include_entity(hass_history) -> None: +def test_get_significant_states_exclude_include_entity(legacy_hass_history) -> None: """Test significant states when excluding domains and include entities. We should not get back every thermostat change unless its specifically included """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["thermostat.test2"] @@ -351,13 +364,13 @@ def test_get_significant_states_exclude_include_entity(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_domain(hass_history) -> None: +def test_get_significant_states_include_domain(legacy_hass_history) -> None: """Test if significant states are returned when including domains. We should get back every thermostat and script changes, but no media player changes. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] del states["media_player.test2"] @@ -372,12 +385,12 @@ def test_get_significant_states_include_domain(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_entity(hass_history) -> None: +def test_get_significant_states_include_entity(legacy_hass_history) -> None: """Test if significant states are returned when including entities. We should only get back changes of the media_player.test entity. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test2"] del states["media_player.test3"] @@ -394,13 +407,13 @@ def test_get_significant_states_include_entity(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include(hass_history) -> None: +def test_get_significant_states_include(legacy_hass_history) -> None: """Test significant states when including domains and entities. We should only get back changes of the media_player.test entity and the thermostat domain. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test2"] del states["media_player.test3"] @@ -420,14 +433,14 @@ def test_get_significant_states_include(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_exclude_domain(hass_history) -> None: +def test_get_significant_states_include_exclude_domain(legacy_hass_history) -> None: """Test if significant states when excluding and including domains. We should get back all the media_player domain changes only since the include wins over the exclude but will exclude everything else. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["thermostat.test"] del states["thermostat.test2"] @@ -445,13 +458,13 @@ def test_get_significant_states_include_exclude_domain(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_exclude_entity(hass_history) -> None: +def test_get_significant_states_include_exclude_entity(legacy_hass_history) -> None: """Test if significant states when excluding and including domains. We should not get back any changes since we include only media_player.test but also exclude it. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test2"] del states["media_player.test3"] @@ -471,13 +484,13 @@ def test_get_significant_states_include_exclude_entity(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_include_exclude(hass_history) -> None: +def test_get_significant_states_include_exclude(legacy_hass_history) -> None: """Test if significant states when in/excluding domains and entities. We should get back changes of the media_player.test2, media_player.test3, and thermostat.test. """ - hass = hass_history + hass = legacy_hass_history zero, four, states = record_states(hass) del states["media_player.test"] del states["thermostat.test2"] @@ -501,13 +514,13 @@ def test_get_significant_states_include_exclude(hass_history) -> None: check_significant_states(hass, zero, four, states, config) -def test_get_significant_states_are_ordered(hass_history) -> None: +def test_get_significant_states_are_ordered(legacy_hass_history) -> None: """Test order of results from get_significant_states. When entity ids are given, the results should be returned with the data in the same order. """ - hass = hass_history + hass = legacy_hass_history zero, four, _states = record_states(hass) entity_ids = ["media_player.test", "media_player.test2"] hist = get_significant_states( @@ -521,9 +534,9 @@ def test_get_significant_states_are_ordered(hass_history) -> None: assert list(hist.keys()) == entity_ids -def test_get_significant_states_only(hass_history) -> None: +def test_get_significant_states_only(legacy_hass_history) -> None: """Test significant states when significant_states_only is set.""" - hass = hass_history + hass = legacy_hass_history entity_id = "sensor.test" def set_state(state, **kwargs): @@ -691,9 +704,13 @@ async def test_fetch_period_api( ) -> None: """Test the fetch period view for history.""" await async_setup_component(hass, "history", {}) - client = await hass_client() - response = await client.get(f"/api/history/period/{dt_util.utcnow().isoformat()}") - assert response.status == HTTPStatus.OK + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}" + ) + assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_use_include_order( @@ -703,9 +720,13 @@ async def test_fetch_period_api_with_use_include_order( await async_setup_component( hass, "history", {history.DOMAIN: {history.CONF_ORDER: True}} ) - client = await hass_client() - response = await client.get(f"/api/history/period/{dt_util.utcnow().isoformat()}") - assert response.status == HTTPStatus.OK + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}" + ) + assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_minimal_response( @@ -714,40 +735,41 @@ async def test_fetch_period_api_with_minimal_response( """Test the fetch period view for history with minimal_response.""" now = dt_util.utcnow() await async_setup_component(hass, "history", {}) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("sensor.power", 0, {"attr": "any"}) + await async_wait_recording_done(hass) + hass.states.async_set("sensor.power", 50, {"attr": "any"}) + await async_wait_recording_done(hass) + hass.states.async_set("sensor.power", 23, {"attr": "any"}) + last_changed = hass.states.get("sensor.power").last_changed + await async_wait_recording_done(hass) + hass.states.async_set("sensor.power", 23, {"attr": "any"}) + await async_wait_recording_done(hass) + client = await hass_client() + response = await client.get( + f"/api/history/period/{now.isoformat()}?filter_entity_id=sensor.power&minimal_response&no_attributes" + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json[0]) == 3 + state_list = response_json[0] - hass.states.async_set("sensor.power", 0, {"attr": "any"}) - await async_wait_recording_done(hass) - hass.states.async_set("sensor.power", 50, {"attr": "any"}) - await async_wait_recording_done(hass) - hass.states.async_set("sensor.power", 23, {"attr": "any"}) - last_changed = hass.states.get("sensor.power").last_changed - await async_wait_recording_done(hass) - hass.states.async_set("sensor.power", 23, {"attr": "any"}) - await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{now.isoformat()}?filter_entity_id=sensor.power&minimal_response&no_attributes" - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json[0]) == 3 - state_list = response_json[0] + assert state_list[0]["entity_id"] == "sensor.power" + assert state_list[0]["attributes"] == {} + assert state_list[0]["state"] == "0" - assert state_list[0]["entity_id"] == "sensor.power" - assert state_list[0]["attributes"] == {} - assert state_list[0]["state"] == "0" + assert "attributes" not in state_list[1] + assert "entity_id" not in state_list[1] + assert state_list[1]["state"] == "50" - assert "attributes" not in state_list[1] - assert "entity_id" not in state_list[1] - assert state_list[1]["state"] == "50" - - assert "attributes" not in state_list[2] - assert "entity_id" not in state_list[2] - assert state_list[2]["state"] == "23" - assert state_list[2]["last_changed"] == json.dumps( - process_timestamp(last_changed), - cls=JSONEncoder, - ).replace('"', "") + assert "attributes" not in state_list[2] + assert "entity_id" not in state_list[2] + assert state_list[2]["state"] == "23" + assert state_list[2]["last_changed"] == json.dumps( + process_timestamp(last_changed), + cls=JSONEncoder, + ).replace('"', "") async def test_fetch_period_api_with_no_timestamp( @@ -755,9 +777,11 @@ async def test_fetch_period_api_with_no_timestamp( ) -> None: """Test the fetch period view for history with no timestamp.""" await async_setup_component(hass, "history", {}) - client = await hass_client() - response = await client.get("/api/history/period") - assert response.status == HTTPStatus.OK + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_client() + response = await client.get("/api/history/period") + assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_include_order( @@ -774,12 +798,14 @@ async def test_fetch_period_api_with_include_order( } }, ) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - params={"filter_entity_id": "non.existing,something.else"}, - ) - assert response.status == HTTPStatus.OK + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + params={"filter_entity_id": "non.existing,something.else"}, + ) + assert response.status == HTTPStatus.OK async def test_fetch_period_api_with_entity_glob_include( @@ -795,19 +821,21 @@ async def test_fetch_period_api_with_entity_glob_include( } }, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.nomatch", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert response_json[0][0]["entity_id"] == "light.kitchen" + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert response_json[0][0]["entity_id"] == "light.kitchen" async def test_fetch_period_api_with_entity_glob_exclude( @@ -827,26 +855,28 @@ async def test_fetch_period_api_with_entity_glob_exclude( } }, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.match", "on") - hass.states.async_set("switch.match", "on") - hass.states.async_set("media_player.test", "on") - hass.states.async_set("binary_sensor.sensor_l", "on") - hass.states.async_set("binary_sensor.sensor_r", "on") - hass.states.async_set("binary_sensor.sensor", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.match", "on") + hass.states.async_set("switch.match", "on") + hass.states.async_set("media_player.test", "on") + hass.states.async_set("binary_sensor.sensor_l", "on") + hass.states.async_set("binary_sensor.sensor_r", "on") + hass.states.async_set("binary_sensor.sensor", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 3 - entities = {state[0]["entity_id"] for state in response_json} - assert entities == {"binary_sensor.sensor", "light.cow", "light.match"} + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 3 + entities = {state[0]["entity_id"] for state in response_json} + assert entities == {"binary_sensor.sensor", "light.cow", "light.match"} async def test_fetch_period_api_with_entity_glob_include_and_exclude( @@ -869,30 +899,32 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( } }, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.match", "on") - hass.states.async_set("light.many_state_changes", "on") - hass.states.async_set("switch.match", "on") - hass.states.async_set("media_player.test", "on") - hass.states.async_set("binary_sensor.exclude", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.match", "on") + hass.states.async_set("light.many_state_changes", "on") + hass.states.async_set("switch.match", "on") + hass.states.async_set("media_player.test", "on") + hass.states.async_set("binary_sensor.exclude", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 4 - entities = {state[0]["entity_id"] for state in response_json} - assert entities == { - "light.many_state_changes", - "light.match", - "media_player.test", - "switch.match", - } + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 4 + entities = {state[0]["entity_id"] for state in response_json} + assert entities == { + "light.many_state_changes", + "light.match", + "media_player.test", + "switch.match", + } async def test_entity_ids_limit_via_api( @@ -904,21 +936,23 @@ async def test_entity_ids_limit_via_api( "history", {"history": {}}, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.nomatch", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 2 - assert response_json[0][0]["entity_id"] == "light.kitchen" - assert response_json[1][0]["entity_id"] == "light.cow" + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 2 + assert response_json[0][0]["entity_id"] == "light.kitchen" + assert response_json[1][0]["entity_id"] == "light.cow" async def test_entity_ids_limit_via_api_with_skip_initial_state( @@ -930,29 +964,31 @@ async def test_entity_ids_limit_via_api_with_skip_initial_state( "history", {"history": {}}, ) - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + hass.states.async_set("light.nomatch", "on") - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 0 + client = await hass_client() + response = await client.get( + f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 0 - when = dt_util.utcnow() - timedelta(minutes=1) - response = await client.get( - f"/api/history/period/{when.isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 2 - assert response_json[0][0]["entity_id"] == "light.kitchen" - assert response_json[1][0]["entity_id"] == "light.cow" + when = dt_util.utcnow() - timedelta(minutes=1) + response = await client.get( + f"/api/history/period/{when.isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json) == 2 + assert response_json[0][0]["entity_id"] == "light.kitchen" + assert response_json[1][0]["entity_id"] == "light.cow" async def test_history_during_period( @@ -964,129 +1000,143 @@ async def test_history_during_period( await async_setup_component(hass, "history", {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_wait_recording_done(hass) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_wait_recording_done(hass) - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "end_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "end_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - "minimal_response": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 + await client.send_json( + { + "id": 2, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 2 - sensor_test_history = response["result"]["sensor.test"] - assert len(sensor_test_history) == 3 + sensor_test_history = response["result"]["sensor.test"] + assert len(sensor_test_history) == 3 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert "a" not in sensor_test_history[1] - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) + assert "a" not in sensor_test_history[1] + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[2]["s"] == "on" - assert "a" not in sensor_test_history[2] + assert sensor_test_history[2]["s"] == "on" + assert "a" not in sensor_test_history[2] - await client.send_json( - { - "id": 3, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 3 - sensor_test_history = response["result"]["sensor.test"] + await client.send_json( + { + "id": 3, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 3 + sensor_test_history = response["result"]["sensor.test"] - assert len(sensor_test_history) == 5 + assert len(sensor_test_history) == 5 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"any": "attr"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"any": "attr"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"any": "attr"} + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"any": "attr"} - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"any": "attr"} + assert sensor_test_history[4]["s"] == "on" + assert sensor_test_history[4]["a"] == {"any": "attr"} - await client.send_json( - { - "id": 4, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 4 - sensor_test_history = response["result"]["sensor.test"] + await client.send_json( + { + "id": 4, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": True, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 4 + sensor_test_history = response["result"]["sensor.test"] - assert len(sensor_test_history) == 3 + assert len(sensor_test_history) == 3 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"any": "attr"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"any": "attr"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"any": "attr"} + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"any": "attr"} - assert sensor_test_history[2]["s"] == "on" - assert sensor_test_history[2]["a"] == {"any": "attr"} + assert sensor_test_history[2]["s"] == "on" + assert sensor_test_history[2]["a"] == {"any": "attr"} async def test_history_during_period_impossible_conditions( @@ -1096,56 +1146,58 @@ async def test_history_during_period_impossible_conditions( await async_setup_component(hass, "history", {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_wait_recording_done(hass) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) + await async_wait_recording_done(hass) - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - after = dt_util.utcnow() + after = dt_util.utcnow() - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": after.isoformat(), - "end_time": after.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": False, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 1 - assert response["result"] == {} + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": after.isoformat(), + "end_time": after.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": False, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 1 + assert response["result"] == {} - future = dt_util.utcnow() + timedelta(hours=10) + future = dt_util.utcnow() + timedelta(hours=10) - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": future.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 - assert response["result"] == {} + await client.send_json( + { + "id": 2, + "type": "history/history_during_period", + "start_time": future.isoformat(), + "entity_ids": ["sensor.test"], + "include_start_time_state": True, + "significant_changes_only": True, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 2 + assert response["result"] == {} @pytest.mark.parametrize( @@ -1164,159 +1216,175 @@ async def test_history_during_period_significant_domain( await async_setup_component(hass, "history", {}) await async_setup_component(hass, "sensor", {}) await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "on", attributes={"temperature": "1"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "off", attributes={"temperature": "2"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "off", attributes={"temperature": "3"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "off", attributes={"temperature": "4"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "on", attributes={"temperature": "5"}) - await async_wait_recording_done(hass) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + hass.states.async_set("climate.test", "on", attributes={"temperature": "1"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("climate.test", "off", attributes={"temperature": "2"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("climate.test", "off", attributes={"temperature": "3"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("climate.test", "off", attributes={"temperature": "4"}) + await async_recorder_block_till_done(hass) + hass.states.async_set("climate.test", "on", attributes={"temperature": "5"}) + await async_wait_recording_done(hass) - await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "end_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "end_time": now.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - "minimal_response": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 + await client.send_json( + { + "id": 2, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 2 - sensor_test_history = response["result"]["climate.test"] - assert len(sensor_test_history) == 5 + sensor_test_history = response["result"]["climate.test"] + assert len(sensor_test_history) == 5 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert "a" in sensor_test_history[1] - assert sensor_test_history[1]["s"] == "off" - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) + assert "a" in sensor_test_history[1] + assert sensor_test_history[1]["s"] == "off" + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {} + assert sensor_test_history[4]["s"] == "on" + assert sensor_test_history[4]["a"] == {} - await client.send_json( - { - "id": 3, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 3 - sensor_test_history = response["result"]["climate.test"] + await client.send_json( + { + "id": 3, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 3 + sensor_test_history = response["result"]["climate.test"] - assert len(sensor_test_history) == 5 + assert len(sensor_test_history) == 5 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"temperature": "1"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"temperature": "1"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"temperature": "2"} + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"temperature": "2"} - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"temperature": "5"} + assert sensor_test_history[4]["s"] == "on" + assert sensor_test_history[4]["a"] == {"temperature": "5"} - await client.send_json( - { - "id": 4, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 4 - sensor_test_history = response["result"]["climate.test"] + await client.send_json( + { + "id": 4, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": True, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 4 + sensor_test_history = response["result"]["climate.test"] - assert len(sensor_test_history) == 5 + assert len(sensor_test_history) == 5 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"temperature": "1"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"temperature": "1"} + assert isinstance(sensor_test_history[0]["lu"], float) + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert "lc" not in sensor_test_history[1] # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"temperature": "2"} + assert sensor_test_history[1]["s"] == "off" + assert isinstance(sensor_test_history[1]["lu"], float) + assert ( + "lc" not in sensor_test_history[1] + ) # skipped if the same a last_updated (lu) + assert sensor_test_history[1]["a"] == {"temperature": "2"} - assert sensor_test_history[2]["s"] == "off" - assert sensor_test_history[2]["a"] == {"temperature": "3"} + assert sensor_test_history[2]["s"] == "off" + assert sensor_test_history[2]["a"] == {"temperature": "3"} - assert sensor_test_history[3]["s"] == "off" - assert sensor_test_history[3]["a"] == {"temperature": "4"} + assert sensor_test_history[3]["s"] == "off" + assert sensor_test_history[3]["a"] == {"temperature": "4"} - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"temperature": "5"} + assert sensor_test_history[4]["s"] == "on" + assert sensor_test_history[4]["a"] == {"temperature": "5"} - # Test we impute the state time state - later = dt_util.utcnow() - await client.send_json( - { - "id": 5, - "type": "history/history_during_period", - "start_time": later.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 5 - sensor_test_history = response["result"]["climate.test"] + # Test we impute the state time state + later = dt_util.utcnow() + await client.send_json( + { + "id": 5, + "type": "history/history_during_period", + "start_time": later.isoformat(), + "entity_ids": ["climate.test"], + "include_start_time_state": True, + "significant_changes_only": True, + "no_attributes": False, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 5 + sensor_test_history = response["result"]["climate.test"] - assert len(sensor_test_history) == 1 + assert len(sensor_test_history) == 1 - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"temperature": "5"} - assert sensor_test_history[0]["lu"] == later.timestamp() - assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) + assert sensor_test_history[0]["s"] == "on" + assert sensor_test_history[0]["a"] == {"temperature": "5"} + assert sensor_test_history[0]["lu"] == later.timestamp() + assert ( + "lc" not in sensor_test_history[0] + ) # skipped if the same a last_updated (lu) async def test_history_during_period_bad_start_time( @@ -1328,18 +1396,19 @@ async def test_history_during_period_bad_start_time( "history", {"history": {}}, ) - - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": "cats", - } - ) - response = await client.receive_json() - assert not response["success"] - assert response["error"]["code"] == "invalid_start_time" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": "cats", + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_start_time" async def test_history_during_period_bad_end_time( @@ -1353,16 +1422,17 @@ async def test_history_during_period_bad_end_time( "history", {"history": {}}, ) - - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "end_time": "dogs", - } - ) - response = await client.receive_json() - assert not response["success"] - assert response["error"]["code"] == "invalid_end_time" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/history_during_period", + "start_time": now.isoformat(), + "end_time": "dogs", + } + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_end_time" diff --git a/tests/components/recorder/db_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py index c8c87ca82dd0..9f73e304e9ba 100644 --- a/tests/components/recorder/db_schema_23_with_newer_columns.py +++ b/tests/components/recorder/db_schema_23_with_newer_columns.py @@ -62,6 +62,7 @@ DB_TIMEZONE = "+00:00" TABLE_EVENTS = "events" TABLE_STATES = "states" +TABLE_STATES_META = "states_meta" TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" @@ -73,6 +74,7 @@ TABLE_EVENT_TYPES = "event_types" ALL_TABLES = [ TABLE_STATES, + TABLE_STATES_META, TABLE_EVENTS, TABLE_EVENT_TYPES, TABLE_RECORDER_RUNS, @@ -266,6 +268,10 @@ class States(Base): # type: ignore context_parent_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) # *** Not originally in v23, only added for recorder to startup ok + metadata_id = Column( + Integer, ForeignKey("states_meta.metadata_id"), index=True + ) # *** Not originally in v23, only added for recorder to startup ok + states_meta_rel = relationship("StatesMeta") event = relationship("Events", uselist=False) old_state = relationship("States", remote_side=[state_id]) @@ -326,6 +332,27 @@ class States(Base): # type: ignore return None +# *** Not originally in v23, only added for recorder to startup ok +# This is not being tested by the v23 statistics migration tests +class StatesMeta(Base): # type: ignore[misc,valid-type] + """Metadata for states.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES_META + metadata_id = Column(Integer, Identity(), primary_key=True) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class StatisticResult(TypedDict): """Statistic result data class. diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index f7152cec508b..d7a9ec0af4ec 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -8,6 +8,7 @@ from __future__ import annotations from datetime import datetime, timedelta import json import logging +import time from typing import Any, TypedDict, cast, overload from fnvhash import fnv1a_32 @@ -57,6 +58,7 @@ TABLE_EVENTS = "events" TABLE_EVENT_DATA = "event_data" TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" +TABLE_STATES_META = "states_meta" TABLE_STATE_ATTRIBUTES = "state_attributes" TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" @@ -132,7 +134,7 @@ class Events(Base): # type: ignore[misc,valid-type] time_fired = Column(DATETIME_TYPE, index=True) time_fired_ts = Column( TIMESTAMP_TYPE, index=True - ) # *** Not originally in v30, only added for recorder to startup ok + ) # *** Not originally in v28, only added for recorder to startup ok context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) @@ -275,7 +277,13 @@ class States(Base): # type: ignore[misc,valid-type] Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True ) last_changed = Column(DATETIME_TYPE, default=dt_util.utcnow) + last_changed_ts = Column( + TIMESTAMP_TYPE + ) # *** Not originally in v30, only added for recorder to startup ok last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + last_updated_ts = Column( + TIMESTAMP_TYPE, default=time.time, index=True + ) # *** Not originally in v30, only added for recorder to startup ok old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) attributes_id = Column( Integer, ForeignKey("state_attributes.attributes_id"), index=True @@ -284,6 +292,10 @@ class States(Base): # type: ignore[misc,valid-type] context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) origin_idx = Column(SmallInteger) # 0 is local, 1 is remote + metadata_id = Column( + Integer, ForeignKey("states_meta.metadata_id"), index=True + ) # *** Not originally in v28, only added for recorder to startup ok + states_meta_rel = relationship("StatesMeta") old_state = relationship("States", remote_side=[state_id]) state_attributes = relationship("StateAttributes") @@ -412,6 +424,27 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] return {} +# *** Not originally in v23, only added for recorder to startup ok +# This is not being tested by the v23 statistics migration tests +class StatesMeta(Base): # type: ignore[misc,valid-type] + """Metadata for states.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES_META + metadata_id = Column(Integer, Identity(), primary_key=True) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class StatisticResult(TypedDict): """Statistic result data class. diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index ed9fb89e4644..9c5efaea1d32 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -8,6 +8,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging +import time from typing import Any, TypedDict, cast, overload import ciso8601 @@ -67,6 +68,7 @@ TABLE_EVENT_DATA = "event_data" TABLE_EVENT_TYPES = "event_types" TABLE_STATES = "states" TABLE_STATE_ATTRIBUTES = "state_attributes" +TABLE_STATES_META = "states_meta" TABLE_RECORDER_RUNS = "recorder_runs" TABLE_SCHEMA_CHANGES = "schema_changes" TABLE_STATISTICS = "statistics" @@ -77,6 +79,7 @@ TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" ALL_TABLES = [ TABLE_STATES, TABLE_STATE_ATTRIBUTES, + TABLE_STATES_META, TABLE_EVENTS, TABLE_EVENT_DATA, TABLE_EVENT_TYPES, @@ -370,7 +373,13 @@ class States(Base): # type: ignore[misc,valid-type] Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True ) last_changed = Column(DATETIME_TYPE) + last_changed_ts = Column( + TIMESTAMP_TYPE + ) # *** Not originally in v30, only added for recorder to startup ok last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + last_updated_ts = Column( + TIMESTAMP_TYPE, default=time.time, index=True + ) # *** Not originally in v30, only added for recorder to startup ok old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) attributes_id = Column( Integer, ForeignKey("state_attributes.attributes_id"), index=True @@ -388,6 +397,10 @@ class States(Base): # type: ignore[misc,valid-type] context_parent_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) # *** Not originally in v30, only added for recorder to startup ok + metadata_id = Column( + Integer, ForeignKey("states_meta.metadata_id"), index=True + ) # *** Not originally in v30, only added for recorder to startup ok + states_meta_rel = relationship("StatesMeta") old_state = relationship("States", remote_side=[state_id]) state_attributes = relationship("StateAttributes") @@ -525,6 +538,27 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] return {} +# *** Not originally in v30, only added for recorder to startup ok +# This is not being tested by the v30 statistics migration tests +class StatesMeta(Base): # type: ignore[misc,valid-type] + """Metadata for states.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES_META + metadata_id = Column(Integer, Identity(), primary_key=True) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + "" + ) + + class StatisticsBase: """Statistics base class.""" diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py index 477691fdc26d..896987ee58d0 100644 --- a/tests/components/recorder/test_filters_with_entityfilter.py +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.engine.row import Row from homeassistant.components.recorder import Recorder, get_instance -from homeassistant.components.recorder.db_schema import EventData, Events, States +from homeassistant.components.recorder.db_schema import EventData, Events, StatesMeta from homeassistant.components.recorder.filters import ( Filters, extract_include_exclude_filter_conf, @@ -39,8 +39,8 @@ async def _async_get_states_and_events_with_filter( def _get_states_with_session(): with session_scope(hass=hass) as session: return session.execute( - select(States.entity_id).filter( - sqlalchemy_filter.states_entity_filter() + select(StatesMeta.entity_id).filter( + sqlalchemy_filter.states_metadata_entity_filter() ) ).all() diff --git a/tests/components/recorder/test_filters_with_entityfilter_schema_37.py b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py new file mode 100644 index 000000000000..18879ffc0a5c --- /dev/null +++ b/tests/components/recorder/test_filters_with_entityfilter_schema_37.py @@ -0,0 +1,670 @@ +"""The tests for the recorder filter matching the EntityFilter component.""" +import json +from unittest.mock import patch + +import pytest +from sqlalchemy import select +from sqlalchemy.engine.row import Row + +from homeassistant.components.recorder import Recorder, get_instance +from homeassistant.components.recorder.db_schema import EventData, Events, States +from homeassistant.components.recorder.filters import ( + Filters, + extract_include_exclude_filter_conf, + sqlalchemy_filter_from_include_exclude_conf, +) +from homeassistant.components.recorder.util import session_scope +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entityfilter import ( + CONF_DOMAINS, + CONF_ENTITIES, + CONF_ENTITY_GLOBS, + CONF_EXCLUDE, + CONF_INCLUDE, + convert_include_exclude_filter, +) + +from .common import async_wait_recording_done + + +@pytest.fixture(name="legacy_recorder_mock") +async def legacy_recorder_mock_fixture(recorder_mock): + """Fixture for legacy recorder mock.""" + with patch.object(recorder_mock.states_meta_manager, "active", False): + yield recorder_mock + + +async def _async_get_states_and_events_with_filter( + hass: HomeAssistant, sqlalchemy_filter: Filters, entity_ids: set[str] +) -> tuple[list[Row], list[Row]]: + """Get states from the database based on a filter.""" + for entity_id in entity_ids: + hass.states.async_set(entity_id, STATE_ON) + hass.bus.async_fire("any", {ATTR_ENTITY_ID: entity_id}) + + await async_wait_recording_done(hass) + + def _get_states_with_session(): + with session_scope(hass=hass) as session: + return session.execute( + select(States.entity_id).filter( + sqlalchemy_filter.states_entity_filter() + ) + ).all() + + filtered_states_entity_ids = { + row[0] + for row in await get_instance(hass).async_add_executor_job( + _get_states_with_session + ) + } + + def _get_events_with_session(): + with session_scope(hass=hass) as session: + return session.execute( + select(EventData.shared_data) + .outerjoin(Events, EventData.data_id == Events.data_id) + .filter(sqlalchemy_filter.events_entity_filter()) + ).all() + + filtered_events_entity_ids = set() + for row in await get_instance(hass).async_add_executor_job( + _get_events_with_session + ): + event_data = json.loads(row[0]) + if ATTR_ENTITY_ID not in event_data: + continue + filtered_events_entity_ids.add(json.loads(row[0])[ATTR_ENTITY_ID]) + + return filtered_states_entity_ids, filtered_events_entity_ids + + +async def test_included_and_excluded_simple_case_no_domains( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded without domains.""" + filter_accept = {"sensor.kitchen4", "switch.kitchen"} + filter_reject = { + "light.any", + "switch.other", + "cover.any", + "sensor.weather5", + "light.kitchen", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITY_GLOBS: ["sensor.kitchen*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_ENTITY_GLOBS: ["sensor.weather*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_no_globs( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded without globs.""" + filter_accept = {"switch.bla", "sensor.blu", "sensor.keep"} + filter_reject = {"sensor.bli"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["sensor", "homeassistant"], + CONF_ENTITIES: ["switch.bla"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["switch"], + CONF_ENTITIES: ["sensor.bli"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_without_underscores( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded without underscores.""" + filter_accept = {"light.any", "sensor.kitchen4", "switch.kitchen"} + filter_reject = {"switch.other", "cover.any", "sensor.weather5", "light.kitchen"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["light"], + CONF_ENTITY_GLOBS: ["sensor.kitchen*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["cover"], + CONF_ENTITY_GLOBS: ["sensor.weather*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_with_underscores( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded with underscores.""" + filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} + filter_reject = {"switch.other", "cover.any", "sensor.weather_5", "light.kitchen"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["light"], + CONF_ENTITY_GLOBS: ["sensor.kitchen_*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["cover"], + CONF_ENTITY_GLOBS: ["sensor.weather_*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen_4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather_5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_complex_case( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included and excluded with a complex filter.""" + filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} + filter_reject = { + "camera.one", + "notify.any", + "automation.update_readme", + "automation.update_utilities_cost", + "binary_sensor.iss", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["group.trackers"], + }, + CONF_EXCLUDE: { + CONF_ENTITIES: [ + "automation.update_readme", + "automation.update_utilities_cost", + "binary_sensor.iss", + ], + CONF_DOMAINS: [ + "camera", + "group", + "media_player", + "notify", + "scene", + "sun", + "zone", + ], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_entities_and_excluded_domain( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with included entities and excluded domain.""" + filter_accept = { + "media_player.test", + "media_player.test3", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + filter_reject = { + "thermostat.test2", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["media_player.test", "thermostat.test"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["thermostat"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_domain_included_excluded( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with the same domain included and excluded.""" + filter_accept = { + "media_player.test", + "media_player.test3", + } + filter_reject = { + "thermostat.test2", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["media_player"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["media_player"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_entity_included_excluded( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with the same entity included and excluded.""" + filter_accept = { + "media_player.test", + } + filter_reject = { + "media_player.test3", + "thermostat.test2", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["media_player.test"], + }, + CONF_EXCLUDE: { + CONF_ENTITIES: ["media_player.test"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_entity_included_excluded_include_domain_wins( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test filters with domain and entities and the include domain wins.""" + filter_accept = { + "media_player.test2", + "media_player.test3", + "thermostat.test", + } + filter_reject = { + "thermostat.test2", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["media_player"], + CONF_ENTITIES: ["thermostat.test"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["thermostat"], + CONF_ENTITIES: ["media_player.test"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_specificly_included_entity_always_wins( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test specificlly included entity always wins.""" + filter_accept = { + "media_player.test2", + "media_player.test3", + "thermostat.test", + "binary_sensor.specific_include", + } + filter_reject = { + "binary_sensor.test2", + "binary_sensor.home", + "binary_sensor.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["binary_sensor.specific_include"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["binary_sensor"], + CONF_ENTITY_GLOBS: ["binary_sensor.*"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_specificly_included_entity_always_wins_over_glob( + legacy_recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test specificlly included entity always wins over a glob.""" + filter_accept = { + "sensor.apc900va_status", + "sensor.apc900va_battery_charge", + "sensor.apc900va_battery_runtime", + "sensor.apc900va_load", + "sensor.energy_x", + } + filter_reject = { + "sensor.apc900va_not_included", + } + conf = { + CONF_EXCLUDE: { + CONF_DOMAINS: [ + "updater", + "camera", + "group", + "media_player", + "script", + "sun", + "automation", + "zone", + "weblink", + "scene", + "calendar", + "weather", + "remote", + "notify", + "switch", + "shell_command", + "media_player", + ], + CONF_ENTITY_GLOBS: ["sensor.apc900va_*"], + }, + CONF_INCLUDE: { + CONF_DOMAINS: [ + "binary_sensor", + "climate", + "device_tracker", + "input_boolean", + "sensor", + ], + CONF_ENTITY_GLOBS: ["sensor.energy_*"], + CONF_ENTITIES: [ + "sensor.apc900va_status", + "sensor.apc900va_battery_charge", + "sensor.apc900va_battery_runtime", + "sensor.apc900va_load", + ], + }, + } + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index ccde8c5d1877..e39cb1945f82 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -19,6 +19,7 @@ from homeassistant.components.recorder.db_schema import ( RecorderRuns, StateAttributes, States, + StatesMeta, ) from homeassistant.components.recorder.history import legacy from homeassistant.components.recorder.models import LazyState, process_timestamp @@ -802,34 +803,15 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( instance = await async_setup_recorder_instance(hass, {}) - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - entity_id = "light.test" - await recorder.get_instance(hass).async_add_executor_job( - _add_db_entries, hass, point, [entity_id] - ) + with patch.object(instance.states_meta_manager, "active", False): + start = dt_util.utcnow() + point = start + timedelta(seconds=1) + end = point + timedelta(seconds=1) + entity_id = "light.test" + await recorder.get_instance(hass).async_add_executor_job( + _add_db_entries, hass, point, [entity_id] + ) - no_attributes = True - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, include_start_time_state=False - ) - state = hist[entity_id][0] - assert state.attributes == {} - - no_attributes = False - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, include_start_time_state=False - ) - state = hist[entity_id][0] - assert state.attributes == {"name": "the shared light"} - - with instance.engine.connect() as conn: - conn.execute(text("update states set attributes_id=NULL;")) - conn.execute(text("drop table state_attributes;")) - conn.commit() - - with patch.object(instance, "schema_version", 24): no_attributes = True hist = history.state_changes_during_period( hass, start, end, entity_id, no_attributes, include_start_time_state=False @@ -842,7 +824,37 @@ async def test_state_changes_during_period_query_during_migration_to_schema_25( hass, start, end, entity_id, no_attributes, include_start_time_state=False ) state = hist[entity_id][0] - assert state.attributes == {"name": "the light"} + assert state.attributes == {"name": "the shared light"} + + with instance.engine.connect() as conn: + conn.execute(text("update states set attributes_id=NULL;")) + conn.execute(text("drop table state_attributes;")) + conn.commit() + + with patch.object(instance, "schema_version", 24): + no_attributes = True + hist = history.state_changes_during_period( + hass, + start, + end, + entity_id, + no_attributes, + include_start_time_state=False, + ) + state = hist[entity_id][0] + assert state.attributes == {} + + no_attributes = False + hist = history.state_changes_during_period( + hass, + start, + end, + entity_id, + no_attributes, + include_start_time_state=False, + ) + state = hist[entity_id][0] + assert state.attributes == {"name": "the light"} async def test_get_states_query_during_migration_to_schema_25( @@ -993,7 +1005,14 @@ async def test_get_full_significant_states_handles_empty_last_changed( state_attributes.attributes_id: state_attributes for state_attributes in session.query(StateAttributes) } + metadata_id_to_entity_id = { + states_meta.metadata_id: states_meta + for states_meta in session.query(StatesMeta) + } for db_state in session.query(States): + db_state.entity_id = metadata_id_to_entity_id[ + db_state.metadata_id + ].entity_id state = db_state.to_native() state.attributes = db_state_attributes[ db_state.attributes_id diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index ae37d50f03bb..ef5ec233cf35 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -65,7 +65,9 @@ def db_schema_30(): with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( + core, "EventTypes", old_db_schema.EventTypes + ), patch.object( core, "EventData", old_db_schema.EventData ), patch.object( core, "States", old_db_schema.States @@ -86,7 +88,10 @@ def test_get_full_significant_states_with_session_entity_no_matches( hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) - with session_scope(hass=hass) as session: + instance = recorder.get_instance(hass) + with session_scope(hass=hass) as session, patch.object( + instance.states_meta_manager, "active", False + ): assert ( history.get_full_significant_states_with_session( hass, session, time_before_recorder_ran, now, entity_ids=["demo.id"] @@ -112,7 +117,10 @@ def test_significant_states_with_session_entity_minimal_response_no_matches( hass = hass_recorder() now = dt_util.utcnow() time_before_recorder_ran = now - timedelta(days=1000) - with session_scope(hass=hass) as session: + instance = recorder.get_instance(hass) + with session_scope(hass=hass) as session, patch.object( + instance.states_meta_manager, "active", False + ): assert ( history.get_significant_states_with_session( hass, @@ -152,44 +160,46 @@ def test_state_changes_during_period( """Test state change during period.""" hass = hass_recorder() entity_id = "media_player.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state): - """Set the state.""" - hass.states.set(entity_id, state, attributes) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state, attributes) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) + start = dt_util.utcnow() + point = start + timedelta(seconds=1) + end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("idle") - set_state("YouTube") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("idle") + set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): - states = [ - set_state("idle"), - set_state("Netflix"), - set_state("Plex"), - set_state("YouTube"), - ] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + states = [ + set_state("idle"), + set_state("Netflix"), + set_state("Plex"), + set_state("YouTube"), + ] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): - set_state("Netflix") - set_state("Plex") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end + ): + set_state("Netflix") + set_state("Plex") - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, limit=limit - ) + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes, limit=limit + ) - assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) + assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) def test_state_changes_during_period_descending( @@ -198,96 +208,100 @@ def test_state_changes_during_period_descending( """Test state change during period descending.""" hass = hass_recorder() entity_id = "media_player.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state): - """Set the state.""" - hass.states.set(entity_id, state, {"any": 1}) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state, {"any": 1}) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - point2 = start + timedelta(seconds=1, microseconds=2) - point3 = start + timedelta(seconds=1, microseconds=3) - point4 = start + timedelta(seconds=1, microseconds=4) - end = point + timedelta(seconds=1) + start = dt_util.utcnow() + point = start + timedelta(seconds=1) + point2 = start + timedelta(seconds=1, microseconds=2) + point3 = start + timedelta(seconds=1, microseconds=3) + point4 = start + timedelta(seconds=1, microseconds=4) + end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("idle") - set_state("YouTube") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("idle") + set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): - states = [set_state("idle")] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): - states.append(set_state("Netflix")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 - ): - states.append(set_state("Plex")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 - ): - states.append(set_state("YouTube")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + states = [set_state("idle")] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("Netflix")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 + ): + states.append(set_state("Plex")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 + ): + states.append(set_state("YouTube")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): - set_state("Netflix") - set_state("Plex") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end + ): + set_state("Netflix") + set_state("Plex") - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=False - ) - assert_multiple_states_equal_without_context(states, hist[entity_id]) + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes=False, descending=False + ) + assert_multiple_states_equal_without_context(states, hist[entity_id]) - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=True - ) - assert_multiple_states_equal_without_context( - states, list(reversed(list(hist[entity_id]))) - ) + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes=False, descending=True + ) + assert_multiple_states_equal_without_context( + states, list(reversed(list(hist[entity_id]))) + ) def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> None: """Test number of state changes.""" hass = hass_recorder() entity_id = "sensor.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state): - """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - point2 = point + timedelta(minutes=1, seconds=1) + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + point2 = point + timedelta(minutes=1, seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("1") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): - states.append(set_state("2")) + states = [] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + states.append(set_state("2")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): - states.append(set_state("3")) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("3")) - hist = history.get_last_state_changes(hass, 2, entity_id) + hist = history.get_last_state_changes(hass, 2, entity_id) - assert_multiple_states_equal_without_context(states, hist[entity_id]) + assert_multiple_states_equal_without_context(states, hist[entity_id]) def test_ensure_state_can_be_copied( @@ -300,30 +314,36 @@ def test_ensure_state_can_be_copied( """ hass = hass_recorder() entity_id = "sensor.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state): - """Set the state.""" - hass.states.set(entity_id, state) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("1") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("1") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): - set_state("2") + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + set_state("2") - hist = history.get_last_state_changes(hass, 2, entity_id) + hist = history.get_last_state_changes(hass, 2, entity_id) - assert_states_equal_without_context(copy(hist[entity_id][0]), hist[entity_id][0]) - assert_states_equal_without_context(copy(hist[entity_id][1]), hist[entity_id][1]) + assert_states_equal_without_context( + copy(hist[entity_id][0]), hist[entity_id][0] + ) + assert_states_equal_without_context( + copy(hist[entity_id][1]), hist[entity_id][1] + ) def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> None: @@ -334,9 +354,11 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> media player (attribute changes are not significant and not returned). """ hass = hass_recorder() - zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) def test_get_significant_states_minimal_response( @@ -351,57 +373,59 @@ def test_get_significant_states_minimal_response( media player (attribute changes are not significant and not returned). """ hass = hass_recorder() - zero, four, states = record_states(hass) - hist = history.get_significant_states(hass, zero, four, minimal_response=True) - entites_with_reducable_states = [ - "media_player.test", - "media_player.test3", - ] + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four, minimal_response=True) + entites_with_reducable_states = [ + "media_player.test", + "media_player.test3", + ] - # All states for media_player.test state are reduced - # down to last_changed and state when minimal_response - # is set except for the first state. - # is set. We use JSONEncoder to make sure that are - # pre-encoded last_changed is always the same as what - # will happen with encoding a native state - for entity_id in entites_with_reducable_states: - entity_states = states[entity_id] - for state_idx in range(1, len(entity_states)): - input_state = entity_states[state_idx] - orig_last_changed = orig_last_changed = json.dumps( - process_timestamp(input_state.last_changed), - cls=JSONEncoder, - ).replace('"', "") - orig_state = input_state.state - entity_states[state_idx] = { - "last_changed": orig_last_changed, - "state": orig_state, - } + # All states for media_player.test state are reduced + # down to last_changed and state when minimal_response + # is set except for the first state. + # is set. We use JSONEncoder to make sure that are + # pre-encoded last_changed is always the same as what + # will happen with encoding a native state + for entity_id in entites_with_reducable_states: + entity_states = states[entity_id] + for state_idx in range(1, len(entity_states)): + input_state = entity_states[state_idx] + orig_last_changed = orig_last_changed = json.dumps( + process_timestamp(input_state.last_changed), + cls=JSONEncoder, + ).replace('"', "") + orig_state = input_state.state + entity_states[state_idx] = { + "last_changed": orig_last_changed, + "state": orig_state, + } - assert len(hist) == len(states) - assert_states_equal_without_context( - states["media_player.test"][0], hist["media_player.test"][0] - ) - assert states["media_player.test"][1] == hist["media_player.test"][1] - assert states["media_player.test"][2] == hist["media_player.test"][2] + assert len(hist) == len(states) + assert_states_equal_without_context( + states["media_player.test"][0], hist["media_player.test"][0] + ) + assert states["media_player.test"][1] == hist["media_player.test"][1] + assert states["media_player.test"][2] == hist["media_player.test"][2] - assert_multiple_states_equal_without_context( - states["media_player.test2"], hist["media_player.test2"] - ) - assert_states_equal_without_context( - states["media_player.test3"][0], hist["media_player.test3"][0] - ) - assert states["media_player.test3"][1] == hist["media_player.test3"][1] + assert_multiple_states_equal_without_context( + states["media_player.test2"], hist["media_player.test2"] + ) + assert_states_equal_without_context( + states["media_player.test3"][0], hist["media_player.test3"][0] + ) + assert states["media_player.test3"][1] == hist["media_player.test3"][1] - assert_multiple_states_equal_without_context( - states["script.can_cancel_this_one"], hist["script.can_cancel_this_one"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test2"], hist["thermostat.test2"] - ) + assert_multiple_states_equal_without_context( + states["script.can_cancel_this_one"], hist["script.can_cancel_this_one"] + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["thermostat.test"], hist["thermostat.test"] + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["thermostat.test2"], hist["thermostat.test2"] + ) def test_get_significant_states_with_initial( @@ -414,25 +438,30 @@ def test_get_significant_states_with_initial( media player (attribute changes are not significant and not returned). """ hass = hass_recorder() - zero, four, states = record_states(hass) - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - if entity_id == "media_player.test": - states[entity_id] = states[entity_id][1:] - for state in states[entity_id]: - if state.last_changed == one or state.last_changed == one_with_microsecond: - state.last_changed = one_and_half - state.last_updated = one_and_half + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + one = zero + timedelta(seconds=1) + one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + if entity_id == "media_player.test": + states[entity_id] = states[entity_id][1:] + for state in states[entity_id]: + if ( + state.last_changed == one + or state.last_changed == one_with_microsecond + ): + state.last_changed = one_and_half + state.last_updated = one_and_half - hist = history.get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=True, - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + hist = history.get_significant_states( + hass, + one_and_half, + four, + include_start_time_state=True, + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) def test_get_significant_states_without_initial( @@ -445,27 +474,29 @@ def test_get_significant_states_without_initial( media player (attribute changes are not significant and not returned). """ hass = hass_recorder() - zero, four, states = record_states(hass) - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - states[entity_id] = list( - filter( - lambda s: s.last_changed != one - and s.last_changed != one_with_microsecond, - states[entity_id], + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + one = zero + timedelta(seconds=1) + one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) + one_and_half = zero + timedelta(seconds=1.5) + for entity_id in states: + states[entity_id] = list( + filter( + lambda s: s.last_changed != one + and s.last_changed != one_with_microsecond, + states[entity_id], + ) ) - ) - del states["media_player.test2"] + del states["media_player.test2"] - hist = history.get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=False, - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + hist = history.get_significant_states( + hass, + one_and_half, + four, + include_start_time_state=False, + ) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) def test_get_significant_states_entity_id( @@ -473,15 +504,17 @@ def test_get_significant_states_entity_id( ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + del states["media_player.test2"] + del states["media_player.test3"] + del states["thermostat.test"] + del states["thermostat.test2"] + del states["script.can_cancel_this_one"] - hist = history.get_significant_states(hass, zero, four, ["media_player.test"]) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + hist = history.get_significant_states(hass, zero, four, ["media_player.test"]) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) def test_get_significant_states_multiple_entity_ids( @@ -489,24 +522,26 @@ def test_get_significant_states_multiple_entity_ids( ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() - zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, states = record_states(hass) + del states["media_player.test2"] + del states["media_player.test3"] + del states["thermostat.test2"] + del states["script.can_cancel_this_one"] - hist = history.get_significant_states( - hass, - zero, - four, - ["media_player.test", "thermostat.test"], - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["media_player.test"], hist["media_player.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) + hist = history.get_significant_states( + hass, + zero, + four, + ["media_player.test", "thermostat.test"], + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["media_player.test"], hist["media_player.test"] + ) + assert_multiple_states_equal_without_context_and_last_changed( + states["thermostat.test"], hist["thermostat.test"] + ) def test_get_significant_states_are_ordered( @@ -518,13 +553,16 @@ def test_get_significant_states_are_ordered( in the same order. """ hass = hass_recorder() - zero, four, _states = record_states(hass) - entity_ids = ["media_player.test", "media_player.test2"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - entity_ids = ["media_player.test2", "media_player.test"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids + + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + zero, four, _states = record_states(hass) + entity_ids = ["media_player.test", "media_player.test2"] + hist = history.get_significant_states(hass, zero, four, entity_ids) + assert list(hist.keys()) == entity_ids + entity_ids = ["media_player.test2", "media_player.test"] + hist = history.get_significant_states(hass, zero, four, entity_ids) + assert list(hist.keys()) == entity_ids def test_get_significant_states_only( @@ -533,64 +571,70 @@ def test_get_significant_states_only( """Test significant states when significant_states_only is set.""" hass = hass_recorder() entity_id = "sensor.test" + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): - def set_state(state, **kwargs): - """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) - return hass.states.get(entity_id) + def set_state(state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) - start = dt_util.utcnow() - timedelta(minutes=4) - points = [] - for i in range(1, 4): - points.append(start + timedelta(minutes=i)) + start = dt_util.utcnow() - timedelta(minutes=4) + points = [] + for i in range(1, 4): + points.append(start + timedelta(minutes=i)) - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): - set_state("123", attributes={"attribute": 10.64}) + states = [] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): - # Attributes are different, state not - states.append(set_state("123", attributes={"attribute": 21.42})) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=points[0], + ): + # Attributes are different, state not + states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): - # state is different, attributes not - states.append(set_state("32", attributes={"attribute": 21.42})) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=points[1], + ): + # state is different, attributes not + states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): - # everything is different - states.append(set_state("412", attributes={"attribute": 54.23})) + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=points[2], + ): + # everything is different + states.append(set_state("412", attributes={"attribute": 54.23})) - hist = history.get_significant_states(hass, start, significant_changes_only=True) + hist = history.get_significant_states( + hass, start, significant_changes_only=True + ) - assert len(hist[entity_id]) == 2 - assert not any( - state.last_updated == states[0].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[1].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[2].last_updated for state in hist[entity_id] - ) + assert len(hist[entity_id]) == 2 + assert not any( + state.last_updated == states[0].last_updated for state in hist[entity_id] + ) + assert any( + state.last_updated == states[1].last_updated for state in hist[entity_id] + ) + assert any( + state.last_updated == states[2].last_updated for state in hist[entity_id] + ) - hist = history.get_significant_states(hass, start, significant_changes_only=False) + hist = history.get_significant_states( + hass, start, significant_changes_only=False + ) - assert len(hist[entity_id]) == 3 - assert_multiple_states_equal_without_context_and_last_changed( - states, hist[entity_id] - ) + assert len(hist[entity_id]) == 3 + assert_multiple_states_equal_without_context_and_last_changed( + states, hist[entity_id] + ) def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: @@ -687,23 +731,25 @@ def test_state_changes_during_period_multiple_entities_single_test( generate incorrect results. """ hass = hass_recorder() - start = dt_util.utcnow() - test_entites = {f"sensor.{i}": str(i) for i in range(30)} - for entity_id, value in test_entites.items(): - hass.states.set(entity_id, value) + instance = recorder.get_instance(hass) + with patch.object(instance.states_meta_manager, "active", False): + start = dt_util.utcnow() + test_entites = {f"sensor.{i}": str(i) for i in range(30)} + for entity_id, value in test_entites.items(): + hass.states.set(entity_id, value) - wait_recording_done(hass) - end = dt_util.utcnow() + wait_recording_done(hass) + end = dt_util.utcnow() - hist = history.state_changes_during_period(hass, start, end, None) - for entity_id, value in test_entites.items(): - hist[entity_id][0].state == value + hist = history.state_changes_during_period(hass, start, end, None) + for entity_id, value in test_entites.items(): + hist[entity_id][0].state == value - for entity_id, value in test_entites.items(): - hist = history.state_changes_during_period(hass, start, end, entity_id) - assert len(hist) == 1 - hist[entity_id][0].state == value + for entity_id, value in test_entites.items(): + hist = history.state_changes_during_period(hass, start, end, entity_id) + assert len(hist) == 1 + hist[entity_id][0].state == value - hist = history.state_changes_during_period(hass, start, end, None) - for entity_id, value in test_entites.items(): - hist[entity_id][0].state == value + hist = history.state_changes_during_period(hass, start, end, None) + for entity_id, value in test_entites.items(): + hist[entity_id][0].state == value diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index c46d77677af1..d6162dd20e20 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -43,6 +43,7 @@ from homeassistant.components.recorder.db_schema import ( RecorderRuns, StateAttributes, States, + StatesMeta, StatisticsRuns, ) from homeassistant.components.recorder.models import process_timestamp @@ -235,11 +236,14 @@ async def test_saving_state(recorder_mock: Recorder, hass: HomeAssistant) -> Non with session_scope(hass=hass) as session: db_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): + db_state.entity_id = states_meta.entity_id db_states.append(db_state) state = db_state.to_native() state.attributes = db_state_attributes.to_native() @@ -273,11 +277,14 @@ async def test_saving_state_with_nul( with session_scope(hass=hass) as session: db_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): + db_state.entity_id = states_meta.entity_id db_states.append(db_state) state = db_state.to_native() state.attributes = db_state_attributes.to_native() @@ -542,11 +549,16 @@ def _add_entities(hass, entity_ids): with session_scope(hass=hass) as session: states = [] - for state, state_attributes in session.query(States, StateAttributes).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): - native_state = state.to_native() - native_state.attributes = state_attributes.to_native() + db_state.entity_id = states_meta.entity_id + native_state = db_state.to_native() + native_state.attributes = db_state_attributes.to_native() states.append(native_state) return states @@ -761,7 +773,11 @@ def test_saving_state_and_removing_entity( wait_recording_done(hass) with session_scope(hass=hass) as session: - states = list(session.query(States)) + states = list( + session.query(StatesMeta.entity_id, States.state) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .order_by(States.last_updated_ts) + ) assert len(states) == 3 assert states[0].entity_id == entity_id assert states[0].state == STATE_LOCKED @@ -784,11 +800,16 @@ def test_saving_state_with_oversized_attributes( states = [] with session_scope(hass=hass) as session: - for state, state_attributes in session.query(States, StateAttributes).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): - native_state = state.to_native() - native_state.attributes = state_attributes.to_native() + db_state.entity_id = states_meta.entity_id + native_state = db_state.to_native() + native_state.attributes = db_state_attributes.to_native() states.append(native_state) assert "switch.too_big" in caplog.text @@ -1267,26 +1288,31 @@ def test_saving_sets_old_state(hass_recorder: Callable[..., HomeAssistant]) -> N """Test saving sets old state.""" hass = hass_recorder() - hass.states.set("test.one", "on", {}) - hass.states.set("test.two", "on", {}) + hass.states.set("test.one", "s1", {}) + hass.states.set("test.two", "s2", {}) wait_recording_done(hass) - hass.states.set("test.one", "off", {}) - hass.states.set("test.two", "off", {}) + hass.states.set("test.one", "s3", {}) + hass.states.set("test.two", "s4", {}) wait_recording_done(hass) with session_scope(hass=hass) as session: - states = list(session.query(States)) + states = list( + session.query( + StatesMeta.entity_id, States.state_id, States.old_state_id, States.state + ).outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + ) assert len(states) == 4 + states_by_state = {state.state: state for state in states} - assert states[0].entity_id == "test.one" - assert states[1].entity_id == "test.two" - assert states[2].entity_id == "test.one" - assert states[3].entity_id == "test.two" + assert states_by_state["s1"].entity_id == "test.one" + assert states_by_state["s2"].entity_id == "test.two" + assert states_by_state["s3"].entity_id == "test.one" + assert states_by_state["s4"].entity_id == "test.two" - assert states[0].old_state_id is None - assert states[1].old_state_id is None - assert states[2].old_state_id == states[0].state_id - assert states[3].old_state_id == states[1].state_id + assert states_by_state["s1"].old_state_id is None + assert states_by_state["s2"].old_state_id is None + assert states_by_state["s3"].old_state_id == states_by_state["s1"].state_id + assert states_by_state["s4"].old_state_id == states_by_state["s2"].state_id def test_saving_state_with_serializable_data( @@ -1296,21 +1322,25 @@ def test_saving_state_with_serializable_data( hass = hass_recorder() hass.bus.fire("bad_event", {"fail": CannotSerializeMe()}) - hass.states.set("test.one", "on", {"fail": CannotSerializeMe()}) + hass.states.set("test.one", "s1", {"fail": CannotSerializeMe()}) wait_recording_done(hass) - hass.states.set("test.two", "on", {}) + hass.states.set("test.two", "s2", {}) wait_recording_done(hass) - hass.states.set("test.two", "off", {}) + hass.states.set("test.two", "s3", {}) wait_recording_done(hass) with session_scope(hass=hass) as session: - states = list(session.query(States)) + states = list( + session.query( + StatesMeta.entity_id, States.state_id, States.old_state_id, States.state + ).outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + ) assert len(states) == 2 - - assert states[0].entity_id == "test.two" - assert states[1].entity_id == "test.two" - assert states[0].old_state_id is None - assert states[1].old_state_id == states[0].state_id + states_by_state = {state.state: state for state in states} + assert states_by_state["s2"].entity_id == "test.two" + assert states_by_state["s3"].entity_id == "test.two" + assert states_by_state["s2"].old_state_id is None + assert states_by_state["s3"].old_state_id == states_by_state["s2"].state_id assert "State is not JSON serializable" in caplog.text @@ -1442,6 +1472,7 @@ def test_service_disable_states_not_recording( db_states = list(session.query(States)) assert len(db_states) == 1 assert db_states[0].event_id is None + db_states[0].entity_id = "test.two" assert ( db_states[0].to_native().as_dict() == _state_with_context(hass, "test.two").as_dict() @@ -1554,6 +1585,7 @@ async def test_database_corruption_while_running( with session_scope(hass=hass) as session: db_states = list(session.query(States)) assert len(db_states) == 1 + db_states[0].entity_id = "test.two" assert db_states[0].event_id is None return db_states[0].to_native() @@ -1868,9 +1900,7 @@ def test_deduplication_state_attributes_inside_commit_interval( with session_scope(hass=hass) as session: states = list( - session.query(States) - .filter(States.entity_id == entity_id) - .outerjoin( + session.query(States).outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) ) @@ -1895,7 +1925,7 @@ async def test_async_block_till_done( def _fetch_states(): with session_scope(hass=hass) as session: - return list(session.query(States).filter(States.entity_id == entity_id)) + return list(session.query(States)) await async_block_recorder(hass, 0.1) await instance.async_block_till_done() @@ -2098,11 +2128,14 @@ async def test_excluding_attributes_by_integration( with session_scope(hass=hass) as session: db_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): + db_state.entity_id = states_meta.entity_id db_states.append(db_state) state = db_state.to_native() state.attributes = db_state_attributes.to_native() diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 062013e72800..060d1bcb743d 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -28,10 +28,13 @@ from homeassistant.components.recorder.db_schema import ( EventTypes, RecorderRuns, States, + StatesMeta, ) from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.tasks import ( ContextIDMigrationTask, + EntityIDMigrationTask, + EntityIDPostMigrationTask, EventTypeIDMigrationTask, ) from homeassistant.components.recorder.util import session_scope @@ -54,10 +57,13 @@ ORIG_TZ = dt_util.DEFAULT_TIME_ZONE def _get_native_states(hass, entity_id): with session_scope(hass=hass) as session: - return [ - state.to_native() - for state in session.query(States).filter(States.entity_id == entity_id) - ] + instance = recorder.get_instance(hass) + metadata_id = instance.states_meta_manager.get(entity_id, session) + states = [] + for dbstate in session.query(States).filter(States.metadata_id == metadata_id): + dbstate.entity_id = entity_id + states.append(dbstate.to_native()) + return states async def test_schema_update_calls(recorder_db_url: str, hass: HomeAssistant) -> None: @@ -764,3 +770,121 @@ async def test_migrate_event_type_ids( events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) assert len(events_by_type["event_type_one"]) == 2 assert len(events_by_type["event_type_two"]) == 1 + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_migrate_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + States( + entity_id="sensor.two", + state="two_2", + last_updated_ts=2.252529, + ), + States( + entity_id="sensor.two", + state="two_1", + last_updated_ts=3.152529, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDMigrationTask()) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = ( + session.query( + States.state, + States.metadata_id, + States.last_updated_ts, + StatesMeta.entity_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .all() + ) + assert len(states) == 3 + result = {} + for state in states: + result.setdefault(state.entity_id, []).append( + { + "state_id": state.entity_id, + "last_updated_ts": state.last_updated_ts, + "state": state.state, + } + ) + return result + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + assert len(states_by_entity_id["sensor.two"]) == 2 + assert len(states_by_entity_id["sensor.one"]) == 1 + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_post_migrate_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + States( + entity_id="sensor.two", + state="two_2", + last_updated_ts=2.252529, + ), + States( + entity_id="sensor.two", + state="two_1", + last_updated_ts=3.152529, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDPostMigrationTask()) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = session.query( + States.state, + States.entity_id, + ).all() + assert len(states) == 3 + return {state.state: state.entity_id for state in states} + + states_by_state = await instance.async_add_executor_job(_fetch_migrated_states) + assert states_by_state["one_1"] is None + assert states_by_state["two_2"] is None + assert states_by_state["two_1"] is None diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index fcabb2e83a8a..b865af68dfd4 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -9,6 +9,7 @@ from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session from homeassistant.components import recorder +from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.const import ( SQLITE_MAX_BIND_VARS, SupportedDialect, @@ -20,6 +21,7 @@ from homeassistant.components.recorder.db_schema import ( RecorderRuns, StateAttributes, States, + StatesMeta, StatisticsRuns, StatisticsShortTerm, ) @@ -670,6 +672,31 @@ async def test_purge_cutoff_date( assert state_attributes.count() == 0 +def _convert_pending_states_to_meta(instance: Recorder, session: Session) -> None: + """Convert pending states to use states_metadata.""" + entity_ids: set[str] = set() + states: set[States] = set() + for object in session: + states_meta_objects: dict[str, StatesMeta] = {} + if isinstance(object, States): + entity_ids.add(object.entity_id) + states.add(object) + + entity_id_to_metadata_ids = instance.states_meta_manager.get_many( + entity_ids, session + ) + + for state in states: + entity_id = state.entity_id + state.entity_id = None + if metadata_id := entity_id_to_metadata_ids.get(entity_id): + state.metadata_id = metadata_id + continue + if entity_id not in states_meta_objects: + states_meta_objects[entity_id] = StatesMeta(entity_id=entity_id) + state.states_meta_rel = states_meta_objects[entity_id] + + @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_filtered_states( async_setup_recorder_instance: RecorderInstanceGenerator, @@ -762,6 +789,7 @@ async def test_purge_filtered_states( time_fired_ts=dt_util.utc_to_timestamp(timestamp), ) ) + _convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) @@ -815,8 +843,10 @@ async def test_purge_filtered_states( events_keep = session.query(Events).filter(Events.event_type == "EVENT_KEEP") assert events_keep.count() == 1 - states_sensor_excluded = session.query(States).filter( - States.entity_id == "sensor.excluded" + states_sensor_excluded = ( + session.query(States) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .filter(StatesMeta.entity_id == "sensor.excluded") ) assert states_sensor_excluded.count() == 0 @@ -880,6 +910,7 @@ async def test_purge_filtered_states_to_empty( timestamp, event_id * days, ) + _convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) @@ -955,6 +986,7 @@ async def test_purge_without_state_attributes_filtered_states_to_empty( time_fired_ts=dt_util.utc_to_timestamp(timestamp), ) ) + _convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) @@ -1179,7 +1211,7 @@ async def test_purge_entities( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: """Test purging of specific entities.""" - await async_setup_recorder_instance(hass) + instance = await async_setup_recorder_instance(hass) async def _purge_entities(hass, entity_ids, domains, entity_globs): service_data = { @@ -1227,6 +1259,7 @@ async def test_purge_entities( timestamp, event_id * days, ) + _convert_pending_states_to_meta(instance, session) def _add_keep_records(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: @@ -1240,6 +1273,7 @@ async def test_purge_entities( timestamp, event_id, ) + _convert_pending_states_to_meta(instance, session) _add_purge_records(hass) _add_keep_records(hass) @@ -1255,8 +1289,10 @@ async def test_purge_entities( states = session.query(States) assert states.count() == 10 - states_sensor_kept = session.query(States).filter( - States.entity_id == "sensor.keep" + states_sensor_kept = ( + session.query(States) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .filter(StatesMeta.entity_id == "sensor.keep") ) assert states_sensor_kept.count() == 10 @@ -1285,8 +1321,10 @@ async def test_purge_entities( states = session.query(States) assert states.count() == 10 - states_sensor_kept = session.query(States).filter( - States.entity_id == "sensor.keep" + states_sensor_kept = ( + session.query(States) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .filter(StatesMeta.entity_id == "sensor.keep") ) assert states_sensor_kept.count() == 10 @@ -1796,3 +1834,103 @@ async def test_purge_old_events_purges_the_event_type_ids( assert finished assert events.count() == 0 assert event_types.count() == 0 + + +async def test_purge_old_states_purges_the_state_metadata_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting old states purges state metadata_ids.""" + instance = await async_setup_recorder_instance(hass) + assert instance.states_meta_manager.active is True + + utcnow = dt_util.utcnow() + five_days_ago = utcnow - timedelta(days=5) + eleven_days_ago = utcnow - timedelta(days=11) + far_past = utcnow - timedelta(days=1000) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + def _insert_states(): + with session_scope(hass=hass) as session: + states_meta_sensor_one = StatesMeta(entity_id="sensor.one") + states_meta_sensor_two = StatesMeta(entity_id="sensor.two") + states_meta_sensor_three = StatesMeta(entity_id="sensor.three") + states_meta_sensor_unused = StatesMeta(entity_id="sensor.unused") + session.add_all( + ( + states_meta_sensor_one, + states_meta_sensor_two, + states_meta_sensor_three, + states_meta_sensor_unused, + ) + ) + session.flush() + for _ in range(5): + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + metadata_id = states_meta_sensor_one.metadata_id + elif event_id < 4: + timestamp = five_days_ago + metadata_id = states_meta_sensor_two.metadata_id + else: + timestamp = utcnow + metadata_id = states_meta_sensor_three.metadata_id + + session.add( + States( + metadata_id=metadata_id, + state="any", + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + return instance.states_meta_manager.get_many( + ["sensor.one", "sensor.two", "sensor.three", "sensor.unused"], + session, + ) + + entity_id_to_metadata_id = await instance.async_add_executor_job(_insert_states) + test_metadata_ids = entity_id_to_metadata_id.values() + with session_scope(hass=hass) as session: + states = session.query(States).where(States.metadata_id.in_(test_metadata_ids)) + states_meta = session.query(StatesMeta).where( + StatesMeta.metadata_id.in_(test_metadata_ids) + ) + + assert states.count() == 30 + assert states_meta.count() == 4 + + # run purge_old_data() + finished = purge_old_data( + instance, + far_past, + repack=False, + ) + assert finished + assert states.count() == 30 + # We should remove the unused entity_id + assert states_meta.count() == 3 + + assert "sensor.unused" not in instance.event_type_manager._id_map + + # we should only have 10 states left since + # only one event type was recorded now + finished = purge_old_data( + instance, + utcnow, + repack=False, + ) + assert finished + assert states.count() == 10 + assert states_meta.count() == 1 + + # Purge everything + finished = purge_old_data( + instance, + utcnow + timedelta(seconds=1), + repack=False, + ) + assert finished + assert states.count() == 0 + assert states_meta.count() == 0 diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 78302f74278f..38622bd45a40 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -18,7 +18,7 @@ from homeassistant.components import recorder from homeassistant.components.recorder import util from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX from homeassistant.components.recorder.db_schema import RecorderRuns -from homeassistant.components.recorder.history.legacy import ( +from homeassistant.components.recorder.history.modern import ( _get_single_entity_states_stmt, ) from homeassistant.components.recorder.models import ( @@ -908,26 +908,25 @@ def test_execute_stmt_lambda_element( with session_scope(hass=hass) as session: # No time window, we always get a list - stmt = _get_single_entity_states_stmt( - instance.schema_version, dt_util.utcnow(), "sensor.on", False - ) + metadata_id = instance.states_meta_manager.get("sensor.on", session) + stmt = _get_single_entity_states_stmt(dt_util.utcnow(), metadata_id, False) rows = util.execute_stmt_lambda_element(session, stmt) assert isinstance(rows, list) assert rows[0].state == new_state.state - assert rows[0].entity_id == new_state.entity_id + assert rows[0].metadata_id == metadata_id # Time window >= 2 days, we get a ChunkedIteratorResult rows = util.execute_stmt_lambda_element(session, stmt, now, one_week_from_now) assert isinstance(rows, ChunkedIteratorResult) row = next(rows) assert row.state == new_state.state - assert row.entity_id == new_state.entity_id + assert row.metadata_id == metadata_id # Time window < 2 days, we get a list rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) assert isinstance(rows, list) assert rows[0].state == new_state.state - assert rows[0].entity_id == new_state.entity_id + assert rows[0].metadata_id == metadata_id with patch.object(session, "execute", MockExecutor): rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 6fe810758fb0..4732299fe4be 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -1,5 +1,6 @@ """The tests for recorder platform migrating data from v30.""" # pylint: disable=invalid-name +import asyncio from datetime import timedelta import importlib import sys @@ -15,12 +16,12 @@ from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.util import session_scope from homeassistant.core import EVENT_STATE_CHANGED, Event, EventOrigin, State from homeassistant.helpers import recorder as recorder_helper -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import wait_recording_done +from .common import async_wait_recording_done -from tests.common import get_test_home_assistant +from tests.common import async_test_home_assistant ORIG_TZ = dt_util.DEFAULT_TIME_ZONE @@ -50,7 +51,7 @@ def _create_engine_test(*args, **kwargs): return engine -def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: """Test we can migrate times.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -88,7 +89,9 @@ def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object( + ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( + core, "EventTypes", old_db_schema.EventTypes + ), patch.object( core, "EventData", old_db_schema.EventData ), patch.object( core, "States", old_db_schema.States @@ -96,46 +99,77 @@ def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: core, "Events", old_db_schema.Events ), patch( CREATE_ENGINE_TARGET, new=_create_engine_test + ), patch( + "homeassistant.components.recorder.Recorder._migrate_context_ids", + ), patch( + "homeassistant.components.recorder.Recorder._migrate_event_type_ids", + ), patch( + "homeassistant.components.recorder.Recorder._migrate_entity_ids", ): - hass = get_test_home_assistant() + hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - wait_recording_done(hass) - wait_recording_done(hass) + assert await async_setup_component( + hass, "recorder", {"recorder": {"db_url": dburl}} + ) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) - with session_scope(hass=hass) as session: - session.add(old_db_schema.Events.from_event(custom_event)) - session.add(old_db_schema.States.from_event(state_changed_event)) + def _add_data(): + with session_scope(hass=hass) as session: + session.add(old_db_schema.Events.from_event(custom_event)) + session.add(old_db_schema.States.from_event(state_changed_event)) - hass.stop() + await recorder.get_instance(hass).async_add_executor_job(_add_data) + await hass.async_block_till_done() + + await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ # Test that the duplicates are removed during migration from schema 23 - hass = get_test_home_assistant() + hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) - with session_scope(hass=hass) as session: - result = list( - session.query(recorder.db_schema.Events).filter( - recorder.db_schema.Events.event_type_id.in_( - select_event_type_ids(("custom_event",)) + assert await async_setup_component( + hass, "recorder", {"recorder": {"db_url": dburl}} + ) + await hass.async_block_till_done() + + # We need to wait for all the migration tasks to complete + # before we can check the database. + for _ in range(5): + await async_wait_recording_done(hass) + + def _get_test_data_from_db(): + with session_scope(hass=hass) as session: + events_result = list( + session.query(recorder.db_schema.Events).filter( + recorder.db_schema.Events.event_type_id.in_( + select_event_type_ids(("custom_event",)) + ) ) ) - ) - assert len(result) == 1 - assert result[0].time_fired_ts == now_timestamp - result = list( - session.query(recorder.db_schema.States).where( - recorder.db_schema.States.entity_id == "sensor.test" + states_result = list( + session.query(recorder.db_schema.States) + .join( + recorder.db_schema.StatesMeta, + recorder.db_schema.States.metadata_id + == recorder.db_schema.StatesMeta.metadata_id, + ) + .where(recorder.db_schema.StatesMeta.entity_id == "sensor.test") ) - ) - assert len(result) == 1 - assert result[0].last_changed_ts == one_second_past_timestamp - assert result[0].last_updated_ts == now_timestamp + session.expunge_all() + return events_result, states_result - hass.stop() + events_result, states_result = await recorder.get_instance( + hass + ).async_add_executor_job(_get_test_data_from_db) + + assert len(events_result) == 1 + assert events_result[0].time_fired_ts == now_timestamp + assert len(states_result) == 1 + assert states_result[0].last_changed_ts == one_second_past_timestamp + assert states_result[0].last_updated_ts == now_timestamp + + await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 55eda6c03b01..ae044c535b5d 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -18,6 +18,7 @@ from homeassistant.components.recorder import ( from homeassistant.components.recorder.db_schema import ( StateAttributes, States, + StatesMeta, StatisticsMeta, ) from homeassistant.components.recorder.models import ( @@ -4735,11 +4736,15 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) def _fetch_states() -> list[State]: with session_scope(hass=hass) as session: native_states = [] - for db_state, db_state_attributes in session.query( - States, StateAttributes - ).outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id + for db_state, db_state_attributes, db_states_meta in ( + session.query(States, StateAttributes, StatesMeta) + .outerjoin( + StateAttributes, + States.attributes_id == StateAttributes.attributes_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) ): + db_state.entity_id = db_states_meta.entity_id state = db_state.to_native() state.attributes = db_state_attributes.to_native() native_states.append(state) diff --git a/tests/conftest.py b/tests/conftest.py index 25ee8143829a..4f7b553955ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1158,6 +1158,16 @@ def enable_migrate_event_type_ids() -> bool: return False +@pytest.fixture +def enable_migrate_entity_ids() -> bool: + """Fixture to control enabling of recorder's entity_id migration. + + To enable context id migration, tests can be marked with: + @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) + """ + return False + + @pytest.fixture def recorder_config() -> dict[str, Any] | None: """Fixture to override recorder config. @@ -1221,6 +1231,9 @@ def hass_recorder( enable_nightly_purge: bool, enable_statistics: bool, enable_statistics_table_validation: bool, + enable_migrate_context_ids: bool, + enable_migrate_event_type_ids: bool, + enable_migrate_entity_ids: bool, hass_storage, ) -> Generator[Callable[..., HomeAssistant], None, None]: """Home Assistant fixture with in-memory recorder.""" @@ -1237,6 +1250,17 @@ def hass_recorder( if enable_statistics_table_validation else itertools.repeat(set()) ) + migrate_context_ids = ( + recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None + ) + migrate_event_type_ids = ( + recorder.Recorder._migrate_event_type_ids + if enable_migrate_event_type_ids + else None + ) + migrate_entity_ids = ( + recorder.Recorder._migrate_entity_ids if enable_migrate_entity_ids else None + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -1249,6 +1273,18 @@ def hass_recorder( "homeassistant.components.recorder.migration.statistics_validate_db_schema", side_effect=stats_validate, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_context_ids", + side_effect=migrate_context_ids, + autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_event_type_ids", + side_effect=migrate_event_type_ids, + autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_entity_ids", + side_effect=migrate_entity_ids, + autospec=True, ): def setup_recorder(config: dict[str, Any] | None = None) -> HomeAssistant: @@ -1302,6 +1338,7 @@ async def async_setup_recorder_instance( enable_statistics_table_validation: bool, enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, + enable_migrate_entity_ids: bool, ) -> AsyncGenerator[RecorderInstanceGenerator, None]: """Yield callable to setup recorder instance.""" # pylint: disable-next=import-outside-toplevel @@ -1325,6 +1362,9 @@ async def async_setup_recorder_instance( if enable_migrate_event_type_ids else None ) + migrate_entity_ids = ( + recorder.Recorder._migrate_entity_ids if enable_migrate_entity_ids else None + ) with patch( "homeassistant.components.recorder.Recorder.async_nightly_tasks", side_effect=nightly, @@ -1345,6 +1385,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.Recorder._migrate_event_type_ids", side_effect=migrate_event_type_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_entity_ids", + side_effect=migrate_entity_ids, + autospec=True, ): async def async_setup_recorder( From e809b636e6e18cc88eb2193a6e592f46a3139c43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 12:44:47 -1000 Subject: [PATCH 0409/1058] Bump rflink to 0.0.65 for python 3.11 (#89601) --- homeassistant/components/rflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 8b9c9165b276..0d0cf218cd07 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/rflink", "iot_class": "assumed_state", "loggers": ["rflink"], - "requirements": ["rflink==0.0.63"] + "requirements": ["rflink==0.0.65"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7cf150b8119a..742344b316bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2246,7 +2246,7 @@ restrictedpython==6.0 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.63 +rflink==0.0.65 # homeassistant.components.ring ring_doorbell==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac6045d58b0b..d631b0ecda2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ reolink-aio==0.5.3 restrictedpython==6.0 # homeassistant.components.rflink -rflink==0.0.63 +rflink==0.0.65 # homeassistant.components.ring ring_doorbell==0.7.2 From 459ea048ba12c42f4abe1edcc46cb3f5bd8a748a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 14:07:05 -1000 Subject: [PATCH 0410/1058] Fix old indices never being removed with PostgreSQL (#89599) --- .../components/recorder/db_schema.py | 2 +- .../components/recorder/migration.py | 159 ++++++++++++------ .../components/recorder/test_v32_migration.py | 14 +- 3 files changed, 117 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 7aecf2a57ca4..5e0a78dad3b3 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 38 +SCHEMA_VERSION = 39 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 392a829cb84b..c13a03145777 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -180,7 +180,9 @@ def migrate_schema( with session_scope(session=session_maker()) as session: session.add(SchemaChanges(schema_version=new_version)) - _LOGGER.info("Upgrade to version %s done", new_version) + # Log at the same level as the long schema changes + # so its clear that the upgrade is done + _LOGGER.warning("Upgrade to version %s done", new_version) if schema_errors := schema_status.statistics_schema_errors: _LOGGER.warning( @@ -215,11 +217,12 @@ def _create_index( _LOGGER.debug("Creating %s index", index_name) _LOGGER.warning( ( - "Adding index `%s` to database. Note: this can take several " + "Adding index `%s` to table `%s`. Note: this can take several " "minutes on large databases and slow computers. Please " "be patient!" ), index_name, + table_name, ) with session_scope(session=session_maker()) as session: try: @@ -250,51 +253,74 @@ def _drop_index( string here is generated from the method parameters without sanitizing. DO NOT USE THIS FUNCTION IN ANY OPERATION THAT TAKES USER INPUT. """ - _LOGGER.debug("Dropping index %s from table %s", index_name, table_name) + _LOGGER.warning( + ( + "Dropping index `%s` from table `%s`. Note: this can take several " + "minutes on large databases and slow computers. Please " + "be patient!" + ), + index_name, + table_name, + ) success = False # Engines like DB2/Oracle - with session_scope(session=session_maker()) as session: - try: - connection = session.connection() - connection.execute(text(f"DROP INDEX {index_name}")) - except SQLAlchemyError: - pass - else: - success = True + with session_scope(session=session_maker()) as session, contextlib.suppress( + SQLAlchemyError + ): + connection = session.connection() + connection.execute(text(f"DROP INDEX {index_name}")) + success = True # Engines like SQLite, SQL Server if not success: - with session_scope(session=session_maker()) as session: - try: - connection = session.connection() - connection.execute( - text( - "DROP INDEX {table}.{index}".format( - index=index_name, table=table_name - ) + with session_scope(session=session_maker()) as session, contextlib.suppress( + SQLAlchemyError + ): + connection = session.connection() + connection.execute( + text( + "DROP INDEX {table}.{index}".format( + index=index_name, table=table_name ) ) - except SQLAlchemyError: - pass - else: - success = True + ) + success = True if not success: # Engines like MySQL, MS Access - with session_scope(session=session_maker()) as session: - try: - connection = session.connection() - connection.execute( - text( - "DROP INDEX {index} ON {table}".format( - index=index_name, table=table_name - ) + with session_scope(session=session_maker()) as session, contextlib.suppress( + SQLAlchemyError + ): + connection = session.connection() + connection.execute( + text( + "DROP INDEX {index} ON {table}".format( + index=index_name, table=table_name ) ) - except SQLAlchemyError: - pass - else: + ) + success = True + + if not success: + # Engines like postgresql may have a prefix + # ex idx_16532_ix_events_event_type_time_fired + with session_scope(session=session_maker()) as session, contextlib.suppress( + SQLAlchemyError + ): + connection = session.connection() + inspector = sqlalchemy.inspect(connection) + indexes = inspector.get_indexes(table_name) + if index_to_drop := next( + ( + possible_index["name"] + for possible_index in indexes + if possible_index["name"] + and possible_index["name"].endswith(f"_{index_name}") + ), + None, + ): + connection.execute(text(f"DROP INDEX {index_to_drop}")) success = True if success: @@ -306,26 +332,9 @@ def _drop_index( if quiet: return - if index_name in ( - "ix_states_entity_id", - "ix_states_context_parent_id", - "ix_statistics_short_term_statistic_id_start", - "ix_statistics_statistic_id_start", - ): - # ix_states_context_parent_id was only there on nightly so we do not want - # to generate log noise or issues about it. - # - # ix_states_entity_id was only there for users who upgraded from schema - # version 8 or earlier. Newer installs will not have it so we do not - # want to generate log noise or issues about it. - # - # ix_statistics_short_term_statistic_id_start and ix_statistics_statistic_id_start - # were only there for users who upgraded from schema version 23 or earlier. - return - _LOGGER.warning( ( - "Failed to drop index %s from table %s. Schema " + "Failed to drop index `%s` from table `%s`. Schema " "Migration will continue; this is not a " "critical operation" ), @@ -902,7 +911,8 @@ def _apply_update( # noqa: C901 # This index is no longer used and can cause MySQL to use the wrong index # when querying the states table. # https://github.com/home-assistant/core/issues/83787 - _drop_index(session_maker, "states", "ix_states_entity_id") + # There was an index cleanup here but its now done in schema 39 + pass elif new_version == 34: # Once we require SQLite >= 3.35.5, we should drop the columns: # ALTER TABLE statistics DROP COLUMN created @@ -964,11 +974,14 @@ def _apply_update( # noqa: C901 elif new_version == 35: # Migration is done in two steps to ensure we can start using # the new columns before we wipe the old ones. - _drop_index(session_maker, "statistics", "ix_statistics_statistic_id_start") + _drop_index( + session_maker, "statistics", "ix_statistics_statistic_id_start", quiet=True + ) _drop_index( session_maker, "statistics_short_term", "ix_statistics_short_term_statistic_id_start", + quiet=True, ) # ix_statistics_start and ix_statistics_statistic_id_start are still used # for the post migration cleanup and can be removed in a future version. @@ -994,6 +1007,40 @@ def _apply_update( # noqa: C901 _add_columns(session_maker, "states", [f"metadata_id {big_int}"]) _create_index(session_maker, "states", "ix_states_metadata_id") _create_index(session_maker, "states", "ix_states_metadata_id_last_updated_ts") + elif new_version == 39: + # Dropping indexes with PostgreSQL never worked correctly if there was a prefix + # so we need to cleanup leftover indexes. + _drop_index( + session_maker, "events", "ix_events_event_type_time_fired_ts", quiet=True + ) + _drop_index(session_maker, "events", "ix_events_event_type", quiet=True) + _drop_index( + session_maker, "events", "ix_events_event_type_time_fired", quiet=True + ) + _drop_index(session_maker, "events", "ix_events_time_fired", quiet=True) + _drop_index(session_maker, "events", "ix_events_context_user_id", quiet=True) + _drop_index(session_maker, "events", "ix_events_context_parent_id", quiet=True) + _drop_index( + session_maker, "states", "ix_states_entity_id_last_updated", quiet=True + ) + _drop_index(session_maker, "states", "ix_states_last_updated", quiet=True) + _drop_index(session_maker, "states", "ix_states_entity_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_context_user_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_context_parent_id", quiet=True) + _drop_index(session_maker, "states", "ix_states_created_domain", quiet=True) + _drop_index(session_maker, "states", "ix_states_entity_id_created", quiet=True) + _drop_index(session_maker, "states", "states__state_changes", quiet=True) + _drop_index(session_maker, "states", "states__significant_changes", quiet=True) + _drop_index(session_maker, "states", "ix_states_entity_id_created", quiet=True) + _drop_index( + session_maker, "statistics", "ix_statistics_statistic_id_start", quiet=True + ) + _drop_index( + session_maker, + "statistics_short_term", + "ix_statistics_short_term_statistic_id_start", + quiet=True, + ) else: raise ValueError(f"No schema migration defined for version {new_version}") @@ -1297,8 +1344,8 @@ def migrate_context_ids(instance: Recorder) -> bool: is_done = not (events or states) if is_done: - _drop_index(session_maker, "events", "ix_events_context_id", quiet=True) - _drop_index(session_maker, "states", "ix_states_context_id", quiet=True) + _drop_index(session_maker, "events", "ix_events_context_id") + _drop_index(session_maker, "states", "ix_states_context_id") _LOGGER.debug("Migrating context_ids to binary format: done=%s", is_done) return is_done diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 4732299fe4be..50029e56b215 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -7,7 +7,7 @@ import sys from unittest.mock import patch import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect from sqlalchemy.orm import Session from homeassistant.components import recorder @@ -171,5 +171,17 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: assert states_result[0].last_changed_ts == one_second_past_timestamp assert states_result[0].last_updated_ts == now_timestamp + def _get_events_index_names(): + with session_scope(hass=hass) as session: + return inspect(session.connection()).get_indexes("events") + + indexes = await recorder.get_instance(hass).async_add_executor_job( + _get_events_index_names + ) + index_names = {index["name"] for index in indexes} + + assert "ix_events_context_id_bin" in index_names + assert "ix_events_context_id" not in index_names + await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ From 41b4c5532de0a59de32ff87c8d8179f4732dbb4f Mon Sep 17 00:00:00 2001 From: MarkGodwin Date: Mon, 13 Mar 2023 01:26:34 +0000 Subject: [PATCH 0411/1058] Add Update entities to TP-Link Omada integration (#89562) * Bump tplink-omada * Add omada firmware updates * Excluded from code coverage * Fixed entity name --- .coveragerc | 2 + .../components/tplink_omada/__init__.py | 8 +- .../components/tplink_omada/controller.py | 52 ++++++ .../components/tplink_omada/coordinator.py | 12 +- .../components/tplink_omada/entity.py | 18 +-- .../components/tplink_omada/manifest.json | 2 +- .../components/tplink_omada/switch.py | 26 ++- .../components/tplink_omada/update.py | 149 ++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 10 files changed, 235 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/tplink_omada/controller.py create mode 100644 homeassistant/components/tplink_omada/update.py diff --git a/.coveragerc b/.coveragerc index a533343bf060..20ee077ffa0b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1290,9 +1290,11 @@ omit = homeassistant/components/touchline/climate.py homeassistant/components/tplink_lte/* homeassistant/components/tplink_omada/__init__.py + homeassistant/components/tplink_omada/controller.py homeassistant/components/tplink_omada/coordinator.py homeassistant/components/tplink_omada/entity.py homeassistant/components/tplink_omada/switch.py + homeassistant/components/tplink_omada/update.py homeassistant/components/traccar/device_tracker.py homeassistant/components/tractive/__init__.py homeassistant/components/tractive/binary_sensor.py diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 1e7db69cc95e..709ad5201259 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -16,8 +16,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .config_flow import CONF_SITE, create_omada_client from .const import DOMAIN +from .controller import OmadaSiteController -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -44,11 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from ex site_client = await client.get_site_client(OmadaSite(None, entry.data[CONF_SITE])) - - hass.data[DOMAIN][entry.entry_id] = site_client + controller = OmadaSiteController(hass, site_client) + hass.data[DOMAIN][entry.entry_id] = controller await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py new file mode 100644 index 000000000000..b42cb37ff76f --- /dev/null +++ b/homeassistant/components/tplink_omada/controller.py @@ -0,0 +1,52 @@ +"""Controller for sharing Omada API coordinators between platforms.""" + +from functools import partial + +from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from tplink_omada_client.omadasiteclient import OmadaSiteClient + +from homeassistant.core import HomeAssistant + +from .coordinator import OmadaCoordinator + + +async def _poll_switch_state( + client: OmadaSiteClient, network_switch: OmadaSwitch +) -> dict[str, OmadaSwitchPortDetails]: + """Poll a switch's current state.""" + ports = await client.get_switch_ports(network_switch) + return {p.port_id: p for p in ports} + + +class OmadaSiteController: + """Controller for the Omada SDN site.""" + + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + """Create the controller.""" + self._hass = hass + self._omada_client = omada_client + + self._switch_port_coordinators: dict[ + str, OmadaCoordinator[OmadaSwitchPortDetails] + ] = {} + + @property + def omada_client(self) -> OmadaSiteClient: + """Get the connected client API for the site to manage.""" + return self._omada_client + + def get_switch_port_coordinator( + self, switch: OmadaSwitch + ) -> OmadaCoordinator[OmadaSwitchPortDetails]: + """Get coordinator for network port information of a given switch.""" + if switch.mac not in self._switch_port_coordinators: + self._switch_port_coordinators[switch.mac] = OmadaCoordinator[ + OmadaSwitchPortDetails + ]( + self._hass, + self._omada_client, + f"{switch.name} Ports", + partial(_poll_switch_state, network_switch=switch), + ) + + return self._switch_port_coordinators[switch.mac] diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index 6950e3b6d74a..d73461dc786b 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -6,7 +6,7 @@ from typing import Generic, TypeVar import async_timeout from tplink_omada_client.exceptions import OmadaClientException -from tplink_omada_client.omadaclient import OmadaClient +from tplink_omada_client.omadaclient import OmadaSiteClient from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -22,15 +22,17 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): def __init__( self, hass: HomeAssistant, - omada_client: OmadaClient, - update_func: Callable[[OmadaClient], Awaitable[dict[str, T]]], + omada_client: OmadaSiteClient, + name: str, + update_func: Callable[[OmadaSiteClient], Awaitable[dict[str, T]]], + poll_delay: int = 300, ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, - name="Omada API Data", - update_interval=timedelta(seconds=300), + name=f"Omada API Data - {name}", + update_interval=timedelta(seconds=poll_delay), ) self.omada_client = omada_client self._update_func = update_func diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index c3cc1433b9cc..41cb1c69180a 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -1,5 +1,7 @@ """Base entity definitions.""" -from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from typing import Generic, TypeVar + +from tplink_omada_client.devices import OmadaDevice from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo @@ -8,16 +10,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OmadaCoordinator +T = TypeVar("T") -class OmadaSwitchDeviceEntity( - CoordinatorEntity[OmadaCoordinator[OmadaSwitchPortDetails]] -): - """Common base class for all entities attached to Omada network switches.""" - def __init__( - self, coordinator: OmadaCoordinator[OmadaSwitchPortDetails], device: OmadaSwitch - ) -> None: - """Initialize the switch.""" +class OmadaDeviceEntity(CoordinatorEntity[OmadaCoordinator[T]], Generic[T]): + """Common base class for all entities associated with Omada SDN Devices.""" + + def __init__(self, coordinator: OmadaCoordinator[T], device: OmadaDevice) -> None: + """Initialize the device.""" super().__init__(coordinator) self.device = device diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 005589a2f995..a0fb58b3f6c6 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.1.0"] + "requirements": ["tplink-omada-client==1.1.3"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index dd5ee3168d27..e85b1c181fcd 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -1,12 +1,11 @@ """Support for TPLink Omada device toggle options.""" from __future__ import annotations -from functools import partial from typing import Any from tplink_omada_client.definitions import PoEMode from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails -from tplink_omada_client.omadasiteclient import OmadaSiteClient, SwitchPortOverrides +from tplink_omada_client.omadasiteclient import SwitchPortOverrides from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -15,27 +14,21 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +from .controller import OmadaSiteController from .coordinator import OmadaCoordinator -from .entity import OmadaSwitchDeviceEntity +from .entity import OmadaDeviceEntity POE_SWITCH_ICON = "mdi:ethernet" -async def poll_switch_state( - client: OmadaSiteClient, network_switch: OmadaSwitch -) -> dict[str, OmadaSwitchPortDetails]: - """Poll a switch's current state.""" - ports = await client.get_switch_ports(network_switch) - return {p.port_id: p for p in ports} - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - omada_client: OmadaSiteClient = hass.data[DOMAIN][config_entry.entry_id] + controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] + omada_client = controller.omada_client # Naming fun. Omada switches, as in the network hardware network_switches = await omada_client.get_switches() @@ -44,10 +37,7 @@ async def async_setup_entry( for switch in [ ns for ns in network_switches if ns.device_capabilities.supports_poe ]: - coordinator = OmadaCoordinator[OmadaSwitchPortDetails]( - hass, omada_client, partial(poll_switch_state, network_switch=switch) - ) - + coordinator = controller.get_switch_port_coordinator(switch) await coordinator.async_request_refresh() for idx, port_id in enumerate(coordinator.data): @@ -67,7 +57,9 @@ def get_port_base_name(port: OmadaSwitchPortDetails) -> str: return f"Port {port.port} ({port.name})" -class OmadaNetworkSwitchPortPoEControl(OmadaSwitchDeviceEntity, SwitchEntity): +class OmadaNetworkSwitchPortPoEControl( + OmadaDeviceEntity[OmadaSwitchPortDetails], SwitchEntity +): """Representation of a PoE control toggle on a single network port on a switch.""" _attr_has_entity_name = True diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py new file mode 100644 index 000000000000..5581f61d824a --- /dev/null +++ b/homeassistant/components/tplink_omada/update.py @@ -0,0 +1,149 @@ +"""Support for TPLink Omada device toggle options.""" +from __future__ import annotations + +import logging +from typing import Any, NamedTuple + +from tplink_omada_client.devices import OmadaFirmwareUpdate, OmadaListDevice +from tplink_omada_client.omadasiteclient import OmadaSiteClient + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later + +from .const import DOMAIN +from .controller import OmadaSiteController +from .coordinator import OmadaCoordinator +from .entity import OmadaDeviceEntity + +_LOGGER = logging.getLogger(__name__) + + +class FirmwareUpdateStatus(NamedTuple): + """Firmware update information for Omada SDN devices.""" + + device: OmadaListDevice + firmware: OmadaFirmwareUpdate | None + + +async def _get_firmware_updates(client: OmadaSiteClient) -> list[FirmwareUpdateStatus]: + devices = await client.get_devices() + return [ + FirmwareUpdateStatus( + device=d, + firmware=None + if not d.need_upgrade + else await client.get_firmware_details(d), + ) + for d in devices + ] + + +async def _poll_firmware_updates( + client: OmadaSiteClient, +) -> dict[str, FirmwareUpdateStatus]: + """Poll the state of Omada Devices firmware update availability.""" + return {d.device.mac: d for d in await _get_firmware_updates(client)} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches.""" + controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] + omada_client = controller.omada_client + + devices = await omada_client.get_devices() + + coordinator = OmadaCoordinator[FirmwareUpdateStatus]( + hass, + omada_client, + "Firmware Updates", + _poll_firmware_updates, + poll_delay=6 * 60 * 60, + ) + + entities: list = [] + for device in devices: + entities.append(OmadaDeviceUpdate(coordinator, device)) + + async_add_entities(entities) + await coordinator.async_request_refresh() + + +class OmadaDeviceUpdate( + OmadaDeviceEntity[FirmwareUpdateStatus], + UpdateEntity, +): + """Firmware update status for Omada SDN devices.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + _firmware_update: OmadaFirmwareUpdate = None + + def __init__( + self, + coordinator: OmadaCoordinator[FirmwareUpdateStatus], + device: OmadaListDevice, + ) -> None: + """Initialize the update entity.""" + super().__init__(coordinator, device) + + self._mac = device.mac + self._device = device + self._omada_client = coordinator.omada_client + + self._attr_unique_id = f"{device.mac}_firmware" + self._attr_has_entity_name = True + self._attr_name = "Firmware Update" + self._refresh_state() + + def _refresh_state(self) -> None: + if self._firmware_update and self._device.need_upgrade: + self._attr_installed_version = self._firmware_update.current_version + self._attr_latest_version = self._firmware_update.latest_version + else: + self._attr_installed_version = self._device.firmware_version + self._attr_latest_version = self._device.firmware_version + self._attr_in_progress = self._device.fw_download + + if self._attr_in_progress: + # While firmware update is in progress, poll more frequently + async_call_later(self.hass, 60, self._request_refresh) + + async def _request_refresh(self, _now: Any) -> None: + await self.coordinator.async_request_refresh() + + def release_notes(self) -> str | None: + """Get the release notes for the latest update.""" + if self._firmware_update: + return str(self._firmware_update.release_notes) + return "" + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install a firmware update.""" + if self._firmware_update and ( + version is None or self._firmware_update.latest_version == version + ): + await self._omada_client.start_firmware_upgrade(self._device) + await self.coordinator.async_request_refresh() + else: + _LOGGER.error("Firmware upgrade is not available for %s", self._device.name) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + status = self.coordinator.data[self._mac] + self._device = status.device + self._firmware_update = status.firmware + self._refresh_state() + self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 742344b316bf..a3f91219b3ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2524,7 +2524,7 @@ total_connect_client==2023.2 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.0 +tplink-omada-client==1.1.3 # homeassistant.components.transmission transmission-rpc==3.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d631b0ecda2f..ae5a35f31849 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1785,7 +1785,7 @@ toonapi==0.2.1 total_connect_client==2023.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.0 +tplink-omada-client==1.1.3 # homeassistant.components.transmission transmission-rpc==3.4.0 From 977a07de138bd3d10a22cac43eef28a80d259109 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 15:32:26 -1000 Subject: [PATCH 0412/1058] Generate large history responses in the executor (#89606) --- .../components/history/websocket_api.py | 128 ++++++++++++------ 1 file changed, 83 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 5d0eb59942b2..a761021de553 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -189,21 +189,78 @@ def _async_send_empty_response( """Send an empty response when we know all results are filtered away.""" connection.send_result(msg_id) stream_end_time = end_time or dt_util.utcnow() - _async_send_response(connection, msg_id, start_time, stream_end_time, {}) + connection.send_message( + _generate_websocket_response(msg_id, start_time, stream_end_time, {}) + ) -@callback -def _async_send_response( - connection: ActiveConnection, +def _generate_websocket_response( msg_id: int, start_time: dt, end_time: dt, states: MutableMapping[str, list[dict[str, Any]]], -) -> None: - """Send a response.""" - empty_stream_message = _generate_stream_message(states, start_time, end_time) - empty_response = messages.event_message(msg_id, empty_stream_message) - connection.send_message(JSON_DUMP(empty_response)) +) -> str: + """Generate a websocket response.""" + return JSON_DUMP( + messages.event_message( + msg_id, _generate_stream_message(states, start_time, end_time) + ) + ) + + +def _generate_historical_response( + hass: HomeAssistant, + msg_id: int, + start_time: dt, + end_time: dt, + entity_ids: list[str] | None, + filters: Filters | None, + include_start_time_state: bool, + significant_changes_only: bool, + minimal_response: bool, + no_attributes: bool, + send_empty: bool, +) -> tuple[float, dt | None, str | None]: + """Generate a historical response.""" + states = cast( + MutableMapping[str, list[dict[str, Any]]], + history.get_significant_states( + hass, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + True, + ), + ) + last_time_ts = 0.0 + for state_list in states.values(): + if ( + state_list + and (state_last_time := state_list[-1][COMPRESSED_STATE_LAST_UPDATED]) + > last_time_ts + ): + last_time_ts = cast(float, state_last_time) + + if last_time_ts == 0: + # If we did not send any states ever, we need to send an empty response + # so the websocket client knows it should render/process/consume the + # data. + if not send_empty: + return last_time_ts, None, None + last_time_dt = end_time + else: + last_time_dt = dt_util.utc_from_timestamp(last_time_ts) + + return ( + last_time_ts, + last_time_dt, + _generate_websocket_response(msg_id, start_time, last_time_dt, states), + ) async def _async_send_historical_states( @@ -221,43 +278,24 @@ async def _async_send_historical_states( send_empty: bool, ) -> dt | None: """Fetch history significant_states and send them to the client.""" - states = cast( - MutableMapping[str, list[dict[str, Any]]], - await get_instance(hass).async_add_executor_job( - history.get_significant_states, - hass, - start_time, - end_time, - entity_ids, - filters, - include_start_time_state, - significant_changes_only, - minimal_response, - no_attributes, - True, - ), + instance = get_instance(hass) + last_time_ts, last_time_dt, payload = await instance.async_add_executor_job( + _generate_historical_response, + hass, + msg_id, + start_time, + end_time, + entity_ids, + filters, + include_start_time_state, + significant_changes_only, + minimal_response, + no_attributes, + send_empty, ) - last_time = 0 - - for state_list in states.values(): - if ( - state_list - and (state_last_time := state_list[-1][COMPRESSED_STATE_LAST_UPDATED]) - > last_time - ): - last_time = state_last_time - - if last_time == 0: - # If we did not send any states ever, we need to send an empty response - # so the websocket client knows it should render/process/consume the - # data. - if not send_empty: - return None - last_time_dt = end_time - else: - last_time_dt = dt_util.utc_from_timestamp(last_time) - _async_send_response(connection, msg_id, start_time, last_time_dt, states) - return last_time_dt if last_time != 0 else None + if payload: + connection.send_message(payload) + return last_time_dt if last_time_ts != 0 else None def _history_compressed_state(state: State, no_attributes: bool) -> dict[str, Any]: From 85ca94e9d44e1b4bba995464e214135f1e646877 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 15:33:28 -1000 Subject: [PATCH 0413/1058] Mark database sessions that do not write data as read_only (#89600) * Mark sessions that do not write data as read_only * Mark sessions that do not write data as read_only --- homeassistant/components/history/__init__.py | 2 +- homeassistant/components/logbook/processor.py | 2 +- homeassistant/components/recorder/history/legacy.py | 6 +++--- homeassistant/components/recorder/history/modern.py | 6 +++--- homeassistant/components/recorder/statistics.py | 12 ++++++------ homeassistant/components/recorder/util.py | 10 ++++++++-- 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 05d620583510..36f2f8945c01 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -168,7 +168,7 @@ class HistoryPeriodView(HomeAssistantView): """Fetch significant stats from the database as json.""" timer_start = time.perf_counter() - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: states = history.get_significant_states_with_session( hass, session, diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index aa0bc7495888..cd88dcb73aa4 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -150,7 +150,7 @@ class EventProcessor: # return result.yield_per(1024) - with session_scope(hass=self.hass) as session: + with session_scope(hass=self.hass, read_only=True) as session: metadata_ids: list[int] | None = None if self.entity_ids: instance = get_instance(self.hass) diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 7d7e3d9b4768..b8f27211c717 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -213,7 +213,7 @@ def get_significant_states( compressed_state_format: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Wrap get_significant_states_with_session with an sql session.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return get_significant_states_with_session( hass, session, @@ -488,7 +488,7 @@ def state_changes_during_period( entity_id = entity_id.lower() if entity_id is not None else None entity_ids = [entity_id] if entity_id is not None else None - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: stmt = _state_changed_during_period_stmt( _schema_version(hass), start_time, @@ -558,7 +558,7 @@ def get_last_state_changes( entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: stmt = _get_last_state_changes_stmt( _schema_version(hass), number_of_states, entity_id_lower ) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index dce3b51edf5d..416a83e8739c 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -115,7 +115,7 @@ def get_significant_states( compressed_state_format: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Wrap get_significant_states_with_session with an sql session.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return get_significant_states_with_session( hass, session, @@ -360,7 +360,7 @@ def state_changes_during_period( entity_id = entity_id.lower() if entity_id is not None else None entity_ids = [entity_id] if entity_id is not None else None - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: metadata_id: int | None = None entity_id_to_metadata_id = None if entity_id: @@ -424,7 +424,7 @@ def get_last_state_changes( entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) if not (metadata_id := instance.states_meta_manager.get(entity_id, session)): return {} diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 48bab4b11fd7..473d416d7572 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -925,7 +925,7 @@ def get_metadata( statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: """Return metadata for statistic_ids.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return get_metadata_with_session( session, statistic_ids=statistic_ids, @@ -985,7 +985,7 @@ def list_statistic_ids( statistic_ids_set = set(statistic_ids) if statistic_ids else None # Query the database - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: metadata = get_metadata_with_session( session, statistic_type=statistic_type, statistic_ids=statistic_ids ) @@ -1589,7 +1589,7 @@ def statistic_during_period( result: dict[str, Any] = {} - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id if not ( metadata := get_metadata_with_session(session, statistic_ids=[statistic_id]) @@ -1814,7 +1814,7 @@ def statistics_during_period( If end_time is omitted, returns statistics newer than or equal to start_time. If statistic_ids is omitted, returns statistics for all statistics ids. """ - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: return _statistics_during_period_with_session( hass, session, @@ -1866,7 +1866,7 @@ def _get_last_statistics( ) -> dict[str, list[dict]]: """Return the last number_of_stats statistics for a given statistic_id.""" statistic_ids = [statistic_id] - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) if not metadata: @@ -1953,7 +1953,7 @@ def get_latest_short_term_statistics( metadata: dict[str, tuple[int, StatisticMetaData]] | None = None, ) -> dict[str, list[dict]]: """Return the latest short term statistics for a list of statistic_ids.""" - with session_scope(hass=hass) as session: + with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_ids if not metadata: metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index bfdd8ff5b148..ae09f9fd6a2d 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -110,8 +110,14 @@ def session_scope( hass: HomeAssistant | None = None, session: Session | None = None, exception_filter: Callable[[Exception], bool] | None = None, + read_only: bool = False, ) -> Generator[Session, None, None]: - """Provide a transactional scope around a series of operations.""" + """Provide a transactional scope around a series of operations. + + read_only is used to indicate that the session is only used for reading + data and that no commit is required. It does not prevent the session + from writing and is not a security measure. + """ if session is None and hass is not None: session = get_instance(hass).get_session() @@ -121,7 +127,7 @@ def session_scope( need_rollback = False try: yield session - if session.get_transaction(): + if session.get_transaction() and not read_only: need_rollback = True session.commit() except Exception as err: # pylint: disable=broad-except From b9ac6b4a7c4c5ec000c499490031af66f54aac21 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 15:41:48 -1000 Subject: [PATCH 0414/1058] Improve reliability of context id migration (#89609) * Split context id migration into states and events tasks Since events can finish much earlier than states we would keep looking at the table because states as not done. Make them seperate tasks * add retry dec * fix migration happening twice * another case --- homeassistant/components/recorder/core.py | 27 +++- .../components/recorder/migration.py | 65 ++++++--- homeassistant/components/recorder/tasks.py | 25 +++- tests/components/recorder/test_migrate.py | 138 +++++++++++++++++- .../components/recorder/test_v32_migration.py | 12 +- tests/conftest.py | 38 ++++- 6 files changed, 258 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 630efe195607..4ebd4703b65c 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -97,9 +97,9 @@ from .tasks import ( ChangeStatisticsUnitTask, ClearStatisticsTask, CommitTask, - ContextIDMigrationTask, DatabaseLockTask, EntityIDMigrationTask, + EventsContextIDMigrationTask, EventTask, EventTypeIDMigrationTask, ImportStatisticsTask, @@ -107,6 +107,7 @@ from .tasks import ( PerodicCleanupTask, PurgeTask, RecorderTask, + StatesContextIDMigrationTask, StatisticsTask, StopTask, SynchronizeTask, @@ -654,8 +655,9 @@ class Recorder(threading.Thread): self.migration_is_live = migration.live_migration(schema_status) self.hass.add_job(self.async_connection_success) + database_was_ready = self.migration_is_live or schema_status.valid - if self.migration_is_live or schema_status.valid: + if database_was_ready: # If the migrate is live or the schema is valid, we need to # wait for startup to complete. If its not live, we need to continue # on. @@ -670,7 +672,6 @@ class Recorder(threading.Thread): # Make sure we cleanly close the run if # we restart before startup finishes self._shutdown() - self._activate_and_set_db_ready() return if not schema_status.valid: @@ -692,7 +693,8 @@ class Recorder(threading.Thread): self._shutdown() return - self._activate_and_set_db_ready() + if not database_was_ready: + self._activate_and_set_db_ready() # Catch up with missed statistics with session_scope(session=self.get_session()) as session: @@ -710,9 +712,14 @@ class Recorder(threading.Thread): if ( self.schema_version < 36 or session.execute(has_events_context_ids_to_migrate()).scalar() + ): + self.queue_task(StatesContextIDMigrationTask()) + + if ( + self.schema_version < 36 or session.execute(has_states_context_ids_to_migrate()).scalar() ): - self.queue_task(ContextIDMigrationTask()) + self.queue_task(EventsContextIDMigrationTask()) if ( self.schema_version < 37 @@ -1236,9 +1243,13 @@ class Recorder(threading.Thread): """Run post schema migration tasks.""" migration.post_schema_migration(self, old_version, new_version) - def _migrate_context_ids(self) -> bool: - """Migrate context ids if needed.""" - return migration.migrate_context_ids(self) + def _migrate_states_context_ids(self) -> bool: + """Migrate states context ids if needed.""" + return migration.migrate_states_context_ids(self) + + def _migrate_events_context_ids(self) -> bool: + """Migrate events context ids if needed.""" + return migration.migrate_events_context_ids(self) def _migrate_event_type_ids(self) -> bool: """Migrate event type ids if needed.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index c13a03145777..b1b33dd29e28 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -64,7 +64,7 @@ from .tasks import ( PostSchemaMigrationTask, StatisticsTimestampMigrationCleanupTask, ) -from .util import database_job_retry_wrapper, session_scope +from .util import database_job_retry_wrapper, retryable_database_job, session_scope if TYPE_CHECKING: from . import Recorder @@ -1301,8 +1301,43 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: return None -def migrate_context_ids(instance: Recorder) -> bool: - """Migrate context_ids to use binary format.""" +@retryable_database_job("migrate states context_ids to binary format") +def migrate_states_context_ids(instance: Recorder) -> bool: + """Migrate states context_ids to use binary format.""" + _to_bytes = _context_id_to_bytes + session_maker = instance.get_session + _LOGGER.debug("Migrating states context_ids to binary format") + with session_scope(session=session_maker()) as session: + if states := session.execute(find_states_context_ids_to_migrate()).all(): + session.execute( + update(States), + [ + { + "state_id": state_id, + "context_id": None, + "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, + "context_user_id": None, + "context_user_id_bin": _to_bytes(context_user_id), + "context_parent_id": None, + "context_parent_id_bin": _to_bytes(context_parent_id), + } + for state_id, context_id, context_user_id, context_parent_id in states + ], + ) + # If there is more work to do return False + # so that we can be called again + is_done = not states + + if is_done: + _drop_index(session_maker, "states", "ix_states_context_id") + + _LOGGER.debug("Migrating states context_ids to binary format: done=%s", is_done) + return is_done + + +@retryable_database_job("migrate events context_ids to binary format") +def migrate_events_context_ids(instance: Recorder) -> bool: + """Migrate events context_ids to use binary format.""" _to_bytes = _context_id_to_bytes session_maker = instance.get_session _LOGGER.debug("Migrating context_ids to binary format") @@ -1323,34 +1358,18 @@ def migrate_context_ids(instance: Recorder) -> bool: for event_id, context_id, context_user_id, context_parent_id in events ], ) - if states := session.execute(find_states_context_ids_to_migrate()).all(): - session.execute( - update(States), - [ - { - "state_id": state_id, - "context_id": None, - "context_id_bin": _to_bytes(context_id) or _EMPTY_CONTEXT_ID, - "context_user_id": None, - "context_user_id_bin": _to_bytes(context_user_id), - "context_parent_id": None, - "context_parent_id_bin": _to_bytes(context_parent_id), - } - for state_id, context_id, context_user_id, context_parent_id in states - ], - ) # If there is more work to do return False # so that we can be called again - is_done = not (events or states) + is_done = not events if is_done: _drop_index(session_maker, "events", "ix_events_context_id") - _drop_index(session_maker, "states", "ix_states_context_id") - _LOGGER.debug("Migrating context_ids to binary format: done=%s", is_done) + _LOGGER.debug("Migrating events context_ids to binary format: done=%s", is_done) return is_done +@retryable_database_job("migrate events event_types to event_type_ids") def migrate_event_type_ids(instance: Recorder) -> bool: """Migrate event_type to event_type_ids.""" session_maker = instance.get_session @@ -1407,6 +1426,7 @@ def migrate_event_type_ids(instance: Recorder) -> bool: return is_done +@retryable_database_job("migrate states entity_ids to states_meta") def migrate_entity_ids(instance: Recorder) -> bool: """Migrate entity_ids to states_meta. @@ -1468,6 +1488,7 @@ def migrate_entity_ids(instance: Recorder) -> bool: return is_done +@retryable_database_job("post migrate states entity_ids to states_meta") def post_migrate_entity_ids(instance: Recorder) -> bool: """Remove old entity_id strings from states. diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 0b99ca742b2f..17b63aad2297 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -346,16 +346,33 @@ class AdjustLRUSizeTask(RecorderTask): @dataclass -class ContextIDMigrationTask(RecorderTask): - """An object to insert into the recorder queue to migrate context ids.""" +class StatesContextIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate states context ids.""" commit_before = False def run(self, instance: Recorder) -> None: """Run context id migration task.""" - if not instance._migrate_context_ids(): # pylint: disable=[protected-access] + if ( + not instance._migrate_states_context_ids() # pylint: disable=[protected-access] + ): # Schedule a new migration task if this one didn't finish - instance.queue_task(ContextIDMigrationTask()) + instance.queue_task(StatesContextIDMigrationTask()) + + +@dataclass +class EventsContextIDMigrationTask(RecorderTask): + """An object to insert into the recorder queue to migrate events context ids.""" + + commit_before = False + + def run(self, instance: Recorder) -> None: + """Run context id migration task.""" + if ( + not instance._migrate_events_context_ids() # pylint: disable=[protected-access] + ): + # Schedule a new migration task if this one didn't finish + instance.queue_task(EventsContextIDMigrationTask()) @dataclass diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 060d1bcb743d..c9d0be5973fe 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -32,10 +32,11 @@ from homeassistant.components.recorder.db_schema import ( ) from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.tasks import ( - ContextIDMigrationTask, EntityIDMigrationTask, EntityIDPostMigrationTask, + EventsContextIDMigrationTask, EventTypeIDMigrationTask, + StatesContextIDMigrationTask, ) from homeassistant.components.recorder.util import session_scope from homeassistant.core import HomeAssistant @@ -558,7 +559,7 @@ def test_raise_if_exception_missing_empty_cause_str() -> None: @pytest.mark.parametrize("enable_migrate_context_ids", [True]) -async def test_migrate_context_ids( +async def test_migrate_events_context_ids( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: """Test we can migrate old uuid context ids and ulid context ids to binary format.""" @@ -632,7 +633,7 @@ async def test_migrate_context_ids( await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder - instance.queue_task(ContextIDMigrationTask()) + instance.queue_task(EventsContextIDMigrationTask()) await async_recorder_block_till_done(hass) def _object_as_dict(obj): @@ -701,6 +702,137 @@ async def test_migrate_context_ids( assert invalid_context_id_event["context_parent_id_bin"] is None +@pytest.mark.parametrize("enable_migrate_context_ids", [True]) +async def test_migrate_states_context_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate old uuid context ids and ulid context ids to binary format.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + test_uuid = uuid.uuid4() + uuid_hex = test_uuid.hex + uuid_bin = test_uuid.bytes + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add_all( + ( + States( + entity_id="state.old_uuid_context_id", + last_updated_ts=1677721632.452529, + context_id=uuid_hex, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + States( + entity_id="state.empty_context_id", + last_updated_ts=1677721632.552529, + context_id=None, + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + States( + entity_id="state.ulid_context_id", + last_updated_ts=1677721632.552529, + context_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", + context_id_bin=None, + context_user_id="9400facee45711eaa9308bfd3d19e474", + context_user_id_bin=None, + context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", + context_parent_id_bin=None, + ), + States( + entity_id="state.invalid_context_id", + last_updated_ts=1677721632.552529, + context_id="invalid", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), + ) + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(StatesContextIDMigrationTask()) + await async_recorder_block_till_done(hass) + + def _object_as_dict(obj): + return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + events = ( + session.query(States) + .filter( + States.entity_id.in_( + [ + "state.old_uuid_context_id", + "state.empty_context_id", + "state.ulid_context_id", + "state.invalid_context_id", + ] + ) + ) + .all() + ) + assert len(events) == 4 + return {state.entity_id: _object_as_dict(state) for state in events} + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + + old_uuid_context_id = states_by_entity_id["state.old_uuid_context_id"] + assert old_uuid_context_id["context_id"] is None + assert old_uuid_context_id["context_user_id"] is None + assert old_uuid_context_id["context_parent_id"] is None + assert old_uuid_context_id["context_id_bin"] == uuid_bin + assert old_uuid_context_id["context_user_id_bin"] is None + assert old_uuid_context_id["context_parent_id_bin"] is None + + empty_context_id = states_by_entity_id["state.empty_context_id"] + assert empty_context_id["context_id"] is None + assert empty_context_id["context_user_id"] is None + assert empty_context_id["context_parent_id"] is None + assert empty_context_id["context_id_bin"] == b"\x00" * 16 + assert empty_context_id["context_user_id_bin"] is None + assert empty_context_id["context_parent_id_bin"] is None + + ulid_context_id = states_by_entity_id["state.ulid_context_id"] + assert ulid_context_id["context_id"] is None + assert ulid_context_id["context_user_id"] is None + assert ulid_context_id["context_parent_id"] is None + assert ( + bytes_to_ulid(ulid_context_id["context_id_bin"]) == "01ARZ3NDEKTSV4RRFFQ69G5FAV" + ) + assert ( + ulid_context_id["context_user_id_bin"] + == b"\x94\x00\xfa\xce\xe4W\x11\xea\xa90\x8b\xfd=\x19\xe4t" + ) + assert ( + bytes_to_ulid(ulid_context_id["context_parent_id_bin"]) + == "01ARZ3NDEKTSV4RRFFQ69G5FA2" + ) + + invalid_context_id = states_by_entity_id["state.invalid_context_id"] + assert invalid_context_id["context_id"] is None + assert invalid_context_id["context_user_id"] is None + assert invalid_context_id["context_parent_id"] is None + assert invalid_context_id["context_id_bin"] == b"\x00" * 16 + assert invalid_context_id["context_user_id_bin"] is None + assert invalid_context_id["context_parent_id_bin"] is None + + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) async def test_migrate_event_type_ids( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 50029e56b215..467dc2961c66 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -86,6 +86,7 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: EventOrigin.local, time_fired=now, ) + number_of_migrations = 5 with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION @@ -100,11 +101,15 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: ), patch( CREATE_ENGINE_TARGET, new=_create_engine_test ), patch( - "homeassistant.components.recorder.Recorder._migrate_context_ids", + "homeassistant.components.recorder.Recorder._migrate_events_context_ids", + ), patch( + "homeassistant.components.recorder.Recorder._migrate_states_context_ids", ), patch( "homeassistant.components.recorder.Recorder._migrate_event_type_ids", ), patch( "homeassistant.components.recorder.Recorder._migrate_entity_ids", + ), patch( + "homeassistant.components.recorder.Recorder._post_migrate_entity_ids" ): hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) @@ -122,8 +127,10 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: await recorder.get_instance(hass).async_add_executor_job(_add_data) await hass.async_block_till_done() + await recorder.get_instance(hass).async_block_till_done() await hass.async_stop() + await hass.async_block_till_done() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -137,7 +144,8 @@ async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: # We need to wait for all the migration tasks to complete # before we can check the database. - for _ in range(5): + for _ in range(number_of_migrations): + await recorder.get_instance(hass).async_block_till_done() await async_wait_recording_done(hass) def _get_test_data_from_db(): diff --git a/tests/conftest.py b/tests/conftest.py index 4f7b553955ee..0307e65d2722 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1250,8 +1250,15 @@ def hass_recorder( if enable_statistics_table_validation else itertools.repeat(set()) ) - migrate_context_ids = ( - recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None + migrate_states_context_ids = ( + recorder.Recorder._migrate_states_context_ids + if enable_migrate_context_ids + else None + ) + migrate_events_context_ids = ( + recorder.Recorder._migrate_events_context_ids + if enable_migrate_context_ids + else None ) migrate_event_type_ids = ( recorder.Recorder._migrate_event_type_ids @@ -1274,8 +1281,12 @@ def hass_recorder( side_effect=stats_validate, autospec=True, ), patch( - "homeassistant.components.recorder.Recorder._migrate_context_ids", - side_effect=migrate_context_ids, + "homeassistant.components.recorder.Recorder._migrate_events_context_ids", + side_effect=migrate_events_context_ids, + autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_states_context_ids", + side_effect=migrate_states_context_ids, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_event_type_ids", @@ -1354,8 +1365,15 @@ async def async_setup_recorder_instance( if enable_statistics_table_validation else itertools.repeat(set()) ) - migrate_context_ids = ( - recorder.Recorder._migrate_context_ids if enable_migrate_context_ids else None + migrate_states_context_ids = ( + recorder.Recorder._migrate_states_context_ids + if enable_migrate_context_ids + else None + ) + migrate_events_context_ids = ( + recorder.Recorder._migrate_events_context_ids + if enable_migrate_context_ids + else None ) migrate_event_type_ids = ( recorder.Recorder._migrate_event_type_ids @@ -1378,8 +1396,12 @@ async def async_setup_recorder_instance( side_effect=stats_validate, autospec=True, ), patch( - "homeassistant.components.recorder.Recorder._migrate_context_ids", - side_effect=migrate_context_ids, + "homeassistant.components.recorder.Recorder._migrate_events_context_ids", + side_effect=migrate_events_context_ids, + autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._migrate_states_context_ids", + side_effect=migrate_states_context_ids, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_event_type_ids", From 877efc993b5621aca5e089adb52b51e742a9c649 Mon Sep 17 00:00:00 2001 From: Barry Loong Date: Mon, 13 Mar 2023 10:45:25 +0800 Subject: [PATCH 0415/1058] Add support for window device class to google assistant (#89564) --- homeassistant/components/google_assistant/const.py | 1 + tests/components/google_assistant/test_smart_home.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 20c4ab60e88f..bf511f8eaebb 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -161,6 +161,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (cover.DOMAIN, cover.CoverDeviceClass.GARAGE): TYPE_GARAGE, (cover.DOMAIN, cover.CoverDeviceClass.GATE): TYPE_GARAGE, (cover.DOMAIN, cover.CoverDeviceClass.SHUTTER): TYPE_SHUTTER, + (cover.DOMAIN, cover.CoverDeviceClass.WINDOW): TYPE_WINDOW, ( humidifier.DOMAIN, humidifier.HumidifierDeviceClass.DEHUMIDIFIER, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index cf83e47b3bf3..d0a7df5a8638 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1113,6 +1113,7 @@ async def test_device_class_binary_sensor( ("awning", "action.devices.types.AWNING"), ("shutter", "action.devices.types.SHUTTER"), ("curtain", "action.devices.types.CURTAIN"), + ("window", "action.devices.types.WINDOW"), ], ) async def test_device_class_cover( From 0575b9bc88585dc94b654607ca50c5a3bf2eeb67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 16:57:22 -1000 Subject: [PATCH 0416/1058] Increase maximum aiohttp connections to 4096 (#89611) fixes #89408 --- homeassistant/helpers/aiohttp_client.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index f75b8e3aa400..af8fa4d6f4dd 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -39,6 +39,20 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session" +# +# The default connection limit of 100 meant that you could only have +# 100 concurrent connections. +# +# This was effectively a limit of 100 devices and than +# the supervisor API would fail as soon as it was hit. +# +# We now apply the 100 limit per host, so that we can have 100 connections +# to a single host, but can have more than 4096 connections in total to +# prevent a single host from using all available connections. +# +MAXIMUM_CONNECTIONS = 4096 +MAXIMUM_CONNECTIONS_PER_HOST = 100 + class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -261,7 +275,12 @@ def _async_get_connector( else: ssl_context = False - connector = aiohttp.TCPConnector(enable_cleanup_closed=True, ssl=ssl_context) + connector = aiohttp.TCPConnector( + enable_cleanup_closed=True, + ssl=ssl_context, + limit=MAXIMUM_CONNECTIONS, + limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, + ) hass.data[key] = connector async def _async_close_connector(event: Event) -> None: From 4dcf7c626705c845ca958df56e759ac2b40fd7c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 17:03:48 -1000 Subject: [PATCH 0417/1058] Fix history_stats test failing during DST (#89589) Note that there is one test that needs `now()` as it is timezone aware --- tests/components/history_stats/test_sensor.py | 112 +++++++++--------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 74194bed6019..4d705fefcc4a 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -30,7 +30,7 @@ async def test_setup(recorder_mock: Recorder, hass: HomeAssistant) -> None: "platform": "history_stats", "entity_id": "binary_sensor.test_id", "state": "on", - "start": "{{ now().replace(hour=0)" + "start": "{{ utcnow().replace(hour=0)" ".replace(minute=0).replace(second=0) }}", "duration": "02:00", "name": "Test", @@ -54,7 +54,7 @@ async def test_setup_multiple_states( "platform": "history_stats", "entity_id": "binary_sensor.test_id", "state": ["on", "true"], - "start": "{{ now().replace(hour=0)" + "start": "{{ utcnow().replace(hour=0)" ".replace(minute=0).replace(second=0) }}", "duration": "02:00", "name": "Test", @@ -76,7 +76,7 @@ async def test_setup_multiple_states( "entity_id": "binary_sensor.test_id", "name": "Test", "state": "on", - "start": "{{ now() }}", + "start": "{{ utcnow() }}", "duration": "TEST", }, { @@ -84,15 +84,15 @@ async def test_setup_multiple_states( "entity_id": "binary_sensor.test_id", "name": "Test", "state": "on", - "start": "{{ now() }}", + "start": "{{ utcnow() }}", }, { "platform": "history_stats", "entity_id": "binary_sensor.test_id", "name": "Test", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "duration": "01:00", }, ], @@ -226,7 +226,7 @@ async def test_reload(recorder_mock: Recorder, hass: HomeAssistant) -> None: "entity_id": "binary_sensor.test_id", "name": "test", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", "duration": "01:00", }, }, @@ -292,8 +292,8 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) -> "entity_id": "input_select.test_id", "name": "sensor1", "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -301,8 +301,8 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) -> "entity_id": "unknown.test_id", "name": "sensor2", "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -310,8 +310,8 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) -> "entity_id": "input_select.test_id", "name": "sensor3", "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -319,8 +319,8 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) -> "entity_id": "input_select.test_id", "name": "sensor4", "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -371,8 +371,8 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: "entity_id": "binary_sensor.test_id", "name": "sensor1", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -380,8 +380,8 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: "entity_id": "binary_sensor.test_id", "name": "sensor2", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -390,7 +390,7 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: "name": "sensor3", "state": "on", "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -398,8 +398,8 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: "entity_id": "binary_sensor.test_id", "name": "sensor4", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -453,8 +453,8 @@ async def test_async_on_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor1", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -462,8 +462,8 @@ async def test_async_on_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor2", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -471,8 +471,8 @@ async def test_async_on_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor3", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -480,8 +480,8 @@ async def test_async_on_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor4", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -531,8 +531,8 @@ async def test_async_off_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor1", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -540,8 +540,8 @@ async def test_async_off_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor2", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -549,8 +549,8 @@ async def test_async_off_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor3", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -558,8 +558,8 @@ async def test_async_off_entire_period( "entity_id": "binary_sensor.test_on_id", "name": "on_sensor4", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -1228,7 +1228,7 @@ async def test_measure_from_end_going_backwards( "name": "sensor1", "state": "on", "duration": {"hours": 1}, - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -1237,7 +1237,7 @@ async def test_measure_from_end_going_backwards( "name": "sensor2", "state": "on", "duration": {"hours": 1}, - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -1246,7 +1246,7 @@ async def test_measure_from_end_going_backwards( "name": "sensor3", "state": "on", "duration": {"hours": 1}, - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -1255,7 +1255,7 @@ async def test_measure_from_end_going_backwards( "name": "sensor4", "state": "on", "duration": {"hours": 1}, - "end": "{{ now() }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -1320,8 +1320,8 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None "entity_id": "binary_sensor.test_id", "name": "sensor1", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -1329,8 +1329,8 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None "entity_id": "binary_sensor.test_id", "name": "sensor2", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "time", }, { @@ -1338,8 +1338,8 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None "entity_id": "binary_sensor.test_id", "name": "sensor3", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "count", }, { @@ -1347,8 +1347,8 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None "entity_id": "binary_sensor.test_id", "name": "sensor4", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ utcnow() }}", "type": "ratio", }, ] @@ -1495,8 +1495,8 @@ async def test_device_classes(recorder_mock: Recorder, hass: HomeAssistant) -> N "entity_id": "binary_sensor.test_id", "name": "time", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ as_timestamp(now()) + 3600 }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ as_timestamp(utcnow()) + 3600 }}", "type": "time", }, { @@ -1504,8 +1504,8 @@ async def test_device_classes(recorder_mock: Recorder, hass: HomeAssistant) -> N "entity_id": "binary_sensor.test_id", "name": "count", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ as_timestamp(now()) + 3600 }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ as_timestamp(utcnow()) + 3600 }}", "type": "count", }, { @@ -1513,8 +1513,8 @@ async def test_device_classes(recorder_mock: Recorder, hass: HomeAssistant) -> N "entity_id": "binary_sensor.test_id", "name": "ratio", "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ as_timestamp(now()) + 3600 }}", + "start": "{{ as_timestamp(utcnow()) - 3600 }}", + "end": "{{ as_timestamp(utcnow()) + 3600 }}", "type": "ratio", }, ] From e34853a82ad247f99b1059640fc99220cd056e74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 17:05:48 -1000 Subject: [PATCH 0418/1058] Switch underlying history stats calculation to use seconds (#77857) * Switch history stats to report in seconds Because hours were previously used, the data would always be off because of the loss of resolution when the time being tracked was in a window of more than 12s * Apply suggestions from code review * Update homeassistant/components/history_stats/sensor.py * tweak --- homeassistant/components/history_stats/data.py | 16 ++++++++-------- .../components/history_stats/helpers.py | 2 +- homeassistant/components/history_stats/sensor.py | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 33f32e72292d..d9b331d82bb7 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -18,7 +18,7 @@ MIN_TIME_UTC = datetime.datetime.min.replace(tzinfo=dt_util.UTC) class HistoryStatsState: """The current stats of the history stats.""" - hours_matched: float | None + seconds_matched: float | None match_count: int | None period: tuple[datetime.datetime, datetime.datetime] @@ -125,12 +125,12 @@ class HistoryStats: await self._async_history_from_db(current_period_start, current_period_end) self._previous_run_before_start = False - hours_matched, match_count = self._async_compute_hours_and_changes( + seconds_matched, match_count = self._async_compute_seconds_and_changes( now_timestamp, current_period_start_timestamp, current_period_end_timestamp, ) - self._state = HistoryStatsState(hours_matched, match_count, self._period) + self._state = HistoryStatsState(seconds_matched, match_count, self._period) return self._state async def _async_history_from_db( @@ -162,10 +162,10 @@ class HistoryStats: no_attributes=True, ).get(self.entity_id, []) - def _async_compute_hours_and_changes( + def _async_compute_seconds_and_changes( self, now_timestamp: float, start_timestamp: float, end_timestamp: float ) -> tuple[float, int]: - """Compute the hours matched and changes from the history list and first state.""" + """Compute the seconds matched and changes from the history list and first state.""" # state_changes_during_period is called with include_start_time_state=True # which is the default and always provides the state at the start # of the period @@ -195,6 +195,6 @@ class HistoryStats: measure_end = min(end_timestamp, now_timestamp) elapsed += measure_end - last_state_change_timestamp - # Save value in hours - hours_matched = elapsed / 3600 - return hours_matched, match_count + # Save value in seconds + seconds_matched = elapsed + return seconds_matched, match_count diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py index 23143984f485..0c914e1fd415 100644 --- a/homeassistant/components/history_stats/helpers.py +++ b/homeassistant/components/history_stats/helpers.py @@ -79,7 +79,7 @@ def pretty_ratio( if len(period) != 2 or period[0] == period[1]: return 0.0 - ratio = 100 * 3600 * value / (period[1] - period[0]).total_seconds() + ratio = 100 * value / (period[1] - period[0]).total_seconds() return round(ratio, 1) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index fc3aedfde24c..2b02be17e9aa 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -163,13 +163,13 @@ class HistoryStatsSensor(HistoryStatsSensorBase): def _process_update(self) -> None: """Process an update from the coordinator.""" state = self.coordinator.data - if state is None or state.hours_matched is None: + if state is None or state.seconds_matched is None: self._attr_native_value = None return if self._type == CONF_TYPE_TIME: - self._attr_native_value = round(state.hours_matched, 2) + self._attr_native_value = round(state.seconds_matched / 3600, 2) elif self._type == CONF_TYPE_RATIO: - self._attr_native_value = pretty_ratio(state.hours_matched, state.period) + self._attr_native_value = pretty_ratio(state.seconds_matched, state.period) elif self._type == CONF_TYPE_COUNT: self._attr_native_value = state.match_count From 17c0e187761643cfeb4311dcac73c1cfb859a8b3 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Mon, 13 Mar 2023 08:04:24 +0100 Subject: [PATCH 0419/1058] Code quality update for EDL21 (#89561) Enhance code quality. --- homeassistant/components/edl21/sensor.py | 36 +++--------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 35992b96104b..68b874149a77 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -397,30 +397,7 @@ class EDL21: self._OBIS_BLACKLIST.add(obis) if new_entities: - self._hass.loop.create_task(self.add_entities(new_entities)) - - async def add_entities(self, new_entities: list[EDL21Entity]) -> None: - """Migrate old unique IDs, then add entities to hass.""" - registry = er.async_get(self._hass) - - for entity in new_entities: - old_entity_id = registry.async_get_entity_id( - "sensor", DOMAIN, entity.old_unique_id - ) - if old_entity_id is not None and entity.unique_id is not None: - LOGGER.debug( - "Migrating unique_id from [%s] to [%s]", - entity.old_unique_id, - entity.unique_id, - ) - if registry.async_get_entity_id("sensor", DOMAIN, entity.unique_id): - registry.async_remove(old_entity_id) - else: - registry.async_update_entity( - old_entity_id, new_unique_id=entity.unique_id - ) - - self._async_add_entities(new_entities, update_before_add=True) + self._async_add_entities(new_entities, update_before_add=True) class EDL21Entity(SensorEntity): @@ -480,18 +457,13 @@ class EDL21Entity(SensorEntity): if self._async_remove_dispatcher: self._async_remove_dispatcher() - @property - def old_unique_id(self) -> str: - """Return a less unique ID as used in the first version of edl21.""" - return self._obis - @property def native_value(self) -> str: """Return the value of the last received telegram.""" return self._telegram.get("value") @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Enumerate supported attributes.""" return { self._state_attrs[k]: v @@ -500,7 +472,7 @@ class EDL21Entity(SensorEntity): } @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" if (unit := self._telegram.get("unit")) is None or unit == 0: return None From c45fb85f1771cf484a4fb932bbd70a3daf5c5ba2 Mon Sep 17 00:00:00 2001 From: Stephan Uhle Date: Mon, 13 Mar 2023 08:25:59 +0100 Subject: [PATCH 0420/1058] Bump pysml to 0.0.9 (#89603) --- homeassistant/components/edl21/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index 48bab7d84f13..f6363473def3 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.8"] + "requirements": ["pysml==0.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3f91219b3ea..6740bd800597 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1979,7 +1979,7 @@ pysmartthings==0.7.6 pysmarty==0.8 # homeassistant.components.edl21 -pysml==0.0.8 +pysml==0.0.9 # homeassistant.components.snmp pysnmplib==5.0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae5a35f31849..687fc6c1a1c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1435,7 +1435,7 @@ pysmartapp==0.3.3 pysmartthings==0.7.6 # homeassistant.components.edl21 -pysml==0.0.8 +pysml==0.0.9 # homeassistant.components.snmp pysnmplib==5.0.21 From 1fb11aec266ef140978eceb0f8c599461d498c56 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Mon, 13 Mar 2023 09:07:10 +0100 Subject: [PATCH 0421/1058] Bump aiopvpc to 4.1.0 (#89593) --- homeassistant/components/pvpc_hourly_pricing/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index 89520f079a91..64e6e19086f1 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["aiopvpc"], "quality_scale": "platinum", - "requirements": ["aiopvpc==4.0.1"] + "requirements": ["aiopvpc==4.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6740bd800597..4e25506c9f4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -241,7 +241,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.0.1 +aiopvpc==4.1.0 # homeassistant.components.lidarr # homeassistant.components.radarr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 687fc6c1a1c5..02b7f7959c1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -222,7 +222,7 @@ aiopurpleair==2022.12.1 aiopvapi==2.0.4 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.0.1 +aiopvpc==4.1.0 # homeassistant.components.lidarr # homeassistant.components.radarr From e33cb2ee6028c31dbd495f0650309d1486953957 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Mar 2023 09:10:47 +0100 Subject: [PATCH 0422/1058] Bump actions/cache from 3.3.0 to 3.3.1 (#89617) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 46 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 86972558882a..12884206a4f9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -212,7 +212,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.0 + uses: actions/cache@v3.3.1 with: path: venv key: >- @@ -227,7 +227,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.0 + uses: actions/cache@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -257,7 +257,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -266,7 +266,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.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -303,7 +303,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -312,7 +312,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.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -352,7 +352,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -361,7 +361,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.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -401,7 +401,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -410,7 +410,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.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -439,7 +439,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -448,7 +448,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.0 + uses: actions/cache/restore@v3.3.1 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -563,7 +563,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.0 + uses: actions/cache@v3.3.1 with: path: venv lookup-only: true @@ -572,7 +572,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.0 + uses: actions/cache@v3.3.1 with: path: ${{ env.PIP_CACHE }} key: >- @@ -626,7 +626,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -658,7 +658,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -691,7 +691,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -742,7 +742,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.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -750,7 +750,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.3.0 + uses: actions/cache@v3.3.1 with: path: .mypy_cache key: >- @@ -801,7 +801,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -854,7 +854,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -980,7 +980,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true @@ -1088,7 +1088,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.0 + uses: actions/cache/restore@v3.3.1 with: path: venv fail-on-cache-miss: true From 470b0b5471822210b0cd9c0abd2bab55a2784f35 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 13 Mar 2023 05:23:04 -0300 Subject: [PATCH 0423/1058] Adjust Tuya entity naming (#89616) Fix entity name --- homeassistant/components/tuya/light.py | 2 +- homeassistant/components/tuya/sensor.py | 2 +- homeassistant/components/tuya/switch.py | 58 ++++++++++++------------- 3 files changed, 31 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index ffc00e6f92ca..3546e4545136 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -221,7 +221,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.BASIC_INDICATOR, - name="Indicator Light", + name="Indicator light", entity_category=EntityCategory.CONFIG, ), ), diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 020099ba5d6b..a2cd2d5fc410 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -834,7 +834,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.TOTAL_CLEAN_AREA, - name="Total Cleaning Area", + name="Total cleaning area", icon="mdi:texture-box", state_class=SensorStateClass.TOTAL_INCREASING, ), diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 1b2fdca32601..a7245913e735 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -34,7 +34,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.WARM, - name="Heat Preservation", + name="Heat preservation", entity_category=EntityCategory.CONFIG, ), ), @@ -57,7 +57,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "cwwsq": ( SwitchEntityDescription( key=DPCode.SLOW_FEED, - name="Slow Feed", + name="Slow feed", icon="mdi:speedometer-slow", entity_category=EntityCategory.CONFIG, ), @@ -89,7 +89,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.UV, - name="UV Sterilization", + name="UV sterilization", icon="mdi:lightbulb", entity_category=EntityCategory.CONFIG, ), @@ -109,7 +109,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "dlq": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -152,7 +152,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.SWITCH_6, - name="Sleep Aid", + name="Sleep aid", icon="mdi:power-sleep", ), ), @@ -176,7 +176,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "kg": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -283,7 +283,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.UV, - name="UV Sterilization", + name="UV sterilization", icon="mdi:minus-circle-outline", entity_category=EntityCategory.CONFIG, ), @@ -299,7 +299,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -325,7 +325,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "pc": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -409,7 +409,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -419,13 +419,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "sd": ( SwitchEntityDescription( key=DPCode.SWITCH_DISTURB, - name="Do Not Disturb", + name="Do not disturb", icon="mdi:minus-circle", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.VOICE_SWITCH, - name="Mute Voice", + name="Mute voice", icon="mdi:account-voice", entity_category=EntityCategory.CONFIG, ), @@ -444,38 +444,38 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "sp": ( SwitchEntityDescription( key=DPCode.WIRELESS_BATTERYLOCK, - name="Battery Lock", + name="Battery lock", icon="mdi:battery-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.CRY_DETECTION_SWITCH, icon="mdi:emoticon-cry", - name="Cry Detection", + name="Cry detection", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.DECIBEL_SWITCH, icon="mdi:microphone-outline", - name="Sound Detection", + name="Sound detection", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.RECORD_SWITCH, icon="mdi:record-rec", - name="Video Recording", + name="Video recording", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_RECORD, icon="mdi:record-rec", - name="Motion Recording", + name="Motion recording", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_PRIVATE, icon="mdi:eye-off", - name="Privacy Mode", + name="Privacy mode", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( @@ -487,25 +487,25 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { SwitchEntityDescription( key=DPCode.BASIC_OSD, icon="mdi:watermark", - name="Time Watermark", + name="Time watermark", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_WDR, icon="mdi:watermark", - name="Wide Dynamic Range", + name="Wide dynamic range", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_TRACKING, icon="mdi:motion-sensor", - name="Motion Tracking", + name="Motion tracking", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_SWITCH, icon="mdi:motion-sensor", - name="Motion Alarm", + name="Motion alarm", entity_category=EntityCategory.CONFIG, ), ), @@ -542,7 +542,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -552,7 +552,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "tyndj": ( SwitchEntityDescription( key=DPCode.SWITCH_SAVE_ENERGY, - name="Energy Saving", + name="Energy saving", icon="mdi:leaf", entity_category=EntityCategory.CONFIG, ), @@ -562,13 +562,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "wkf": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.WINDOW_CHECK, - name="Open Window Detection", + name="Open window detection", icon="mdi:window-open", entity_category=EntityCategory.CONFIG, ), @@ -636,13 +636,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.OXYGEN, - name="Oxygen Bar", + name="Oxygen bar", icon="mdi:molecule", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.FAN_COOL, - name="Natural Wind", + name="Natural wind", icon="mdi:weather-windy", entity_category=EntityCategory.CONFIG, ), @@ -654,7 +654,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child Lock", + name="Child lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), From d1ee303e8547396ef220f5229aa56b6963af07ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Mar 2023 22:24:57 -1000 Subject: [PATCH 0424/1058] Drop duplicated indices from recorder database schema (#89613) Drop duplicated indices from schema https://docs.percona.com/percona-toolkit/pt-duplicate-key-checker.html ``` % pt-duplicate-key-checker --databases fresh ALTER TABLE `fresh`.`events` DROP INDEX `ix_events_event_type_id`; ALTER TABLE `fresh`.`states` DROP INDEX `ix_states_metadata_id`; ALTER TABLE `fresh`.`statistics` DROP INDEX `ix_statistics_metadata_id`; ALTER TABLE `fresh`.`statistics_short_term` DROP INDEX `ix_statistics_short_term_metadata_id`; ``` --- homeassistant/components/recorder/db_schema.py | 7 +++---- homeassistant/components/recorder/migration.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 5e0a78dad3b3..b715ef9bc589 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 39 +SCHEMA_VERSION = 40 _LOGGER = logging.getLogger(__name__) @@ -229,7 +229,7 @@ class Events(Base): LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) event_type_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("event_types.event_type_id"), index=True + Integer, ForeignKey("event_types.event_type_id") ) event_data_rel: Mapped[EventData | None] = relationship("EventData") event_type_rel: Mapped[EventTypes | None] = relationship("EventTypes") @@ -426,7 +426,7 @@ class States(Base): LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) metadata_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("states_meta.metadata_id"), index=True + Integer, ForeignKey("states_meta.metadata_id") ) states_meta_rel: Mapped[StatesMeta | None] = relationship("StatesMeta") @@ -617,7 +617,6 @@ class StatisticsBase: metadata_id: Mapped[int | None] = mapped_column( Integer, ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), - index=True, ) start: Mapped[datetime | None] = mapped_column( DATETIME_TYPE, index=True diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index b1b33dd29e28..5b2180773a34 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1041,6 +1041,19 @@ def _apply_update( # noqa: C901 "ix_statistics_short_term_statistic_id_start", quiet=True, ) + elif new_version == 40: + # ix_events_event_type_id is a left-prefix of ix_events_event_type_id_time_fired_ts + _drop_index(session_maker, "events", "ix_events_event_type_id") + # ix_states_metadata_id is a left-prefix of ix_states_metadata_id_last_updated_ts + _drop_index(session_maker, "states", "ix_states_metadata_id") + # ix_statistics_metadata_id is a left-prefix of ix_statistics_statistic_id_start_ts + _drop_index(session_maker, "statistics", "ix_statistics_metadata_id") + # ix_statistics_short_term_metadata_id is a left-prefix of ix_statistics_short_term_statistic_id_start_ts + _drop_index( + session_maker, + "statistics_short_term", + "ix_statistics_short_term_metadata_id", + ) else: raise ValueError(f"No schema migration defined for version {new_version}") From fd5c56fc7dfa7e2e6c1fda31828305a957b330a9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 09:44:20 +0100 Subject: [PATCH 0425/1058] Rename modules named repairs.py which are not repairs platforms (#89618) --- .../components/bayesian/binary_sensor.py | 2 +- .../bayesian/{repairs.py => issues.py} | 2 +- homeassistant/components/hassio/__init__.py | 10 ++++----- .../hassio/{repairs.py => issues.py} | 18 +++++++-------- tests/components/hassio/conftest.py | 2 +- .../{test_repairs.py => test_issues.py} | 22 +++++++++---------- 6 files changed, 28 insertions(+), 28 deletions(-) rename homeassistant/components/bayesian/{repairs.py => issues.py} (97%) rename homeassistant/components/hassio/{repairs.py => issues.py} (92%) rename tests/components/hassio/{test_repairs.py => test_issues.py} (96%) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 77571e1a80f8..06baef1bd0e0 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -60,7 +60,7 @@ from .const import ( DEFAULT_PROBABILITY_THRESHOLD, ) from .helpers import Observation -from .repairs import raise_mirrored_entries, raise_no_prob_given_false +from .issues import raise_mirrored_entries, raise_no_prob_given_false _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bayesian/repairs.py b/homeassistant/components/bayesian/issues.py similarity index 97% rename from homeassistant/components/bayesian/repairs.py rename to homeassistant/components/bayesian/issues.py index 47d7dff6e19a..fbc3a86258d6 100644 --- a/homeassistant/components/bayesian/repairs.py +++ b/homeassistant/components/bayesian/issues.py @@ -1,4 +1,4 @@ -"""Helpers for generating repairs.""" +"""Helpers for generating issues.""" from __future__ import annotations from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 25f3477bff14..0f17c0b52725 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -96,7 +96,7 @@ from .handler import ( # noqa: F401 ) from .http import HassIOView from .ingress import async_setup_ingress_view -from .repairs import SupervisorRepairs +from .issues import SupervisorIssues from .websocket_api import async_load_websocket_api _LOGGER = logging.getLogger(__name__) @@ -125,7 +125,7 @@ DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" -DATA_SUPERVISOR_REPAIRS = "supervisor_repairs" +DATA_SUPERVISOR_ISSUES = "supervisor_issues" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -604,9 +604,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) ) - # Start listening for problems with supervisor and making repairs - hass.data[DATA_SUPERVISOR_REPAIRS] = repairs = SupervisorRepairs(hass, hassio) - await repairs.setup() + # Start listening for problems with supervisor and making issues + hass.data[DATA_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) + await issues.setup() return True diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/issues.py similarity index 92% rename from homeassistant/components/hassio/repairs.py rename to homeassistant/components/hassio/issues.py index 21120d8d5228..a0d51c4806de 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/issues.py @@ -70,11 +70,11 @@ UNHEALTHY_REASONS = { } -class SupervisorRepairs: - """Create repairs from supervisor events.""" +class SupervisorIssues: + """Create issues from supervisor events.""" def __init__(self, hass: HomeAssistant, client: HassIO) -> None: - """Initialize supervisor repairs.""" + """Initialize supervisor issues.""" self._hass = hass self._client = client self._unsupported_reasons: set[str] = set() @@ -87,7 +87,7 @@ class SupervisorRepairs: @unhealthy_reasons.setter def unhealthy_reasons(self, reasons: set[str]) -> None: - """Set unhealthy reasons. Create or delete repairs as necessary.""" + """Set unhealthy reasons. Create or delete issues as necessary.""" for unhealthy in reasons - self.unhealthy_reasons: if unhealthy in UNHEALTHY_REASONS: translation_key = f"unhealthy_{unhealthy}" @@ -119,7 +119,7 @@ class SupervisorRepairs: @unsupported_reasons.setter def unsupported_reasons(self, reasons: set[str]) -> None: - """Set unsupported reasons. Create or delete repairs as necessary.""" + """Set unsupported reasons. Create or delete issues as necessary.""" for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons: if unsupported in UNSUPPORTED_REASONS: translation_key = f"unsupported_{unsupported}" @@ -149,18 +149,18 @@ class SupervisorRepairs: await self.update() async_dispatcher_connect( - self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_repairs + self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues ) async def update(self) -> None: - """Update repairs from Supervisor resolution center.""" + """Update issuess from Supervisor resolution center.""" data = await self._client.get_resolution_info() self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) @callback - def _supervisor_events_to_repairs(self, event: dict[str, Any]) -> None: - """Create repairs from supervisor events.""" + def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: + """Create issues from supervisor events.""" if ATTR_WS_EVENT not in event: return diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 78ae9643d68b..afe641405e30 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -52,7 +52,7 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": []}, ), patch( - "homeassistant.components.hassio.repairs.SupervisorRepairs.setup" + "homeassistant.components.hassio.issues.SupervisorIssues.setup" ), patch( "homeassistant.components.hassio.HassIO.refresh_updates" ): diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_issues.py similarity index 96% rename from tests/components/hassio/test_repairs.py rename to tests/components/hassio/test_issues.py index 8806e641a5bf..5b280d0c8273 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_issues.py @@ -1,4 +1,4 @@ -"""Test repairs from supervisor issues.""" +"""Test issues from supervisor issues.""" from __future__ import annotations import os @@ -145,12 +145,12 @@ def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: } in issues -async def test_unhealthy_repairs( +async def test_unhealthy_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test repairs added for unhealthy systems.""" + """Test issues added for unhealthy systems.""" mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) result = await async_setup_component(hass, "hassio", {}) @@ -166,12 +166,12 @@ async def test_unhealthy_repairs( assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") -async def test_unsupported_repairs( +async def test_unsupported_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test repairs added for unsupported systems.""" + """Test issues added for unsupported systems.""" mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) result = await async_setup_component(hass, "hassio", {}) @@ -189,12 +189,12 @@ async def test_unsupported_repairs( assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") -async def test_unhealthy_repairs_add_remove( +async def test_unhealthy_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test unhealthy repairs added and removed from dispatches.""" + """Test unhealthy issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) result = await async_setup_component(hass, "hassio", {}) @@ -245,12 +245,12 @@ async def test_unhealthy_repairs_add_remove( assert msg["result"] == {"issues": []} -async def test_unsupported_repairs_add_remove( +async def test_unsupported_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Test unsupported repairs added and removed from dispatches.""" + """Test unsupported issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) result = await async_setup_component(hass, "hassio", {}) @@ -301,12 +301,12 @@ async def test_unsupported_repairs_add_remove( assert msg["result"] == {"issues": []} -async def test_reset_repairs_supervisor_restart( +async def test_reset_issues_supervisor_restart( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Unsupported/unhealthy repairs reset on supervisor restart.""" + """Unsupported/unhealthy issues reset on supervisor restart.""" mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) result = await async_setup_component(hass, "hassio", {}) From 5e73ad9cb0eed32860e8946e3afc1b2465d1a02c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Mar 2023 10:45:59 +0100 Subject: [PATCH 0426/1058] Use SnapshotAssertion in SFR sensor tests (#89619) * Use SnapshotAssertion in SFR sensor tests * Name snapshots * Cleanup const.py * Remove name from snapshot --- tests/components/sfr_box/const.py | 141 +--- .../sfr_box/snapshots/test_sensor.ambr | 684 ++++++++++++++++++ tests/components/sfr_box/test_sensor.py | 39 +- 3 files changed, 700 insertions(+), 164 deletions(-) create mode 100644 tests/components/sfr_box/snapshots/test_sensor.ambr diff --git a/tests/components/sfr_box/const.py b/tests/components/sfr_box/const.py index fb1694ebef06..c3ed56e32a70 100644 --- a/tests/components/sfr_box/const.py +++ b/tests/components/sfr_box/const.py @@ -1,12 +1,7 @@ """Constants for SFR Box tests.""" from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass -from homeassistant.components.sensor import ( - ATTR_OPTIONS, - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) +from homeassistant.components.sensor import ATTR_OPTIONS, ATTR_STATE_CLASS from homeassistant.components.sfr_box.const import DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -17,13 +12,9 @@ from homeassistant.const import ( ATTR_STATE, ATTR_SW_VERSION, ATTR_UNIT_OF_MEASUREMENT, - SIGNAL_STRENGTH_DECIBELS, STATE_ON, STATE_UNKNOWN, Platform, - UnitOfDataRate, - UnitOfElectricPotential, - UnitOfTemperature, ) ATTR_DEFAULT_DISABLED = "default_disabled" @@ -58,134 +49,4 @@ EXPECTED_ENTITIES = { ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_reboot", }, ], - Platform.SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_network_infrastructure", - ATTR_OPTIONS: ["adsl", "ftth", "gprs", "unknown"], - ATTR_STATE: "adsl", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_net_infra", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.sfr_box_temperature", - ATTR_STATE: "27.56", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.sfr_box_voltage", - ATTR_STATE: "12251", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_alimvoltage", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.MILLIVOLT, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_line_mode", - ATTR_STATE: "ADSL2+", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_linemode", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_counter", - ATTR_STATE: "16", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_counter", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_crc", - ATTR_STATE: "0", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_crc", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_noise_down", - ATTR_STATE: "5.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_noise_down", - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_noise_up", - ATTR_STATE: "6.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_noise_up", - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_attenuation_down", - ATTR_STATE: "28.5", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_attenuation_down", - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.SIGNAL_STRENGTH, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_attenuation_up", - ATTR_STATE: "20.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_attenuation_up", - ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_rate_down", - ATTR_STATE: "5549", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_rate_down", - ATTR_UNIT_OF_MEASUREMENT: UnitOfDataRate.KILOBITS_PER_SECOND, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_rate_up", - ATTR_STATE: "187", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_rate_up", - ATTR_UNIT_OF_MEASUREMENT: UnitOfDataRate.KILOBITS_PER_SECOND, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_line_status", - ATTR_OPTIONS: [ - "no_defect", - "of_frame", - "loss_of_signal", - "loss_of_power", - "loss_of_signal_quality", - "unknown", - ], - ATTR_STATE: "no_defect", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_line_status", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, - ATTR_ENTITY_ID: "sensor.sfr_box_dsl_training", - ATTR_OPTIONS: [ - "idle", - "g_994_training", - "g_992_started", - "g_922_channel_analysis", - "g_992_message_exchange", - "g_993_started", - "g_993_channel_analysis", - "g_993_message_exchange", - "showtime", - "unknown", - ], - ATTR_STATE: "showtime", - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_training", - }, - ], } diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..a5788a1d6c5e --- /dev/null +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -0,0 +1,684 @@ +# serializer version: 1 +# name: test_sensors + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.0.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sfr_box', + 'e4:5d:51:00:11:22', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'NB6VAC-FXC-r0', + 'name': 'SFR Box', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'NB6VAC-MAIN-R4.0.44k', + 'via_device_id': None, + }) +# --- +# name: test_sensors.1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'adsl', + 'ftth', + 'gprs', + 'unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_network_infrastructure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Network infrastructure', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': 'net_infra', + 'unique_id': 'e4:5d:51:00:11:22_system_net_infra', + 'unit_of_measurement': None, + }), + 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.sfr_box_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': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_system_alimvoltage', + 'unit_of_measurement': , + }), + 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.sfr_box_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': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_system_temperature', + 'unit_of_measurement': , + }), + 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.sfr_box_dsl_line_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DSL line mode', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_linemode', + 'unit_of_measurement': None, + }), + 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.sfr_box_dsl_counter', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DSL counter', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_counter', + 'unit_of_measurement': None, + }), + 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.sfr_box_dsl_crc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DSL CRC', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_crc', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_noise_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL noise down', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_down', + 'unit_of_measurement': 'dB', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_noise_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL noise up', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_up', + 'unit_of_measurement': 'dB', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL attenuation down', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_down', + 'unit_of_measurement': 'dB', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL attenuation up', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_up', + 'unit_of_measurement': 'dB', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sfr_box_dsl_rate_down', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL rate down', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_down', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sfr_box_dsl_rate_up', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL rate up', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_up', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_defect', + 'of_frame', + 'loss_of_signal', + 'loss_of_power', + 'loss_of_signal_quality', + 'unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_line_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL line status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': 'line_status', + 'unique_id': 'e4:5d:51:00:11:22_dsl_line_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'g_994_training', + 'g_992_started', + 'g_922_channel_analysis', + 'g_992_message_exchange', + 'g_993_started', + 'g_993_channel_analysis', + 'g_993_message_exchange', + 'showtime', + 'unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_dsl_training', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL training', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': 'training', + 'unique_id': 'e4:5d:51:00:11:22_dsl_training', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[sensor.sfr_box_dsl_attenuation_down] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL attenuation down', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_down', + 'last_changed': , + 'last_updated': , + 'state': '28.5', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_attenuation_up] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL attenuation up', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_attenuation_up', + 'last_changed': , + 'last_updated': , + 'state': '20.8', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_counter] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box DSL counter', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_counter', + 'last_changed': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_crc] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box DSL CRC', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_crc', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_line_mode] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'SFR Box DSL line mode', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_line_mode', + 'last_changed': , + 'last_updated': , + 'state': 'ADSL2+', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_line_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box DSL line status', + 'options': list([ + 'no_defect', + 'of_frame', + 'loss_of_signal', + 'loss_of_power', + 'loss_of_signal_quality', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_line_status', + 'last_changed': , + 'last_updated': , + 'state': 'no_defect', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_noise_down] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL noise down', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_noise_down', + 'last_changed': , + 'last_updated': , + 'state': '5.8', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_noise_up] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'SFR Box DSL noise up', + 'state_class': , + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_noise_up', + 'last_changed': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_rate_down] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'SFR Box DSL rate down', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_rate_down', + 'last_changed': , + 'last_updated': , + 'state': '5549', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_rate_up] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'SFR Box DSL rate up', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_rate_up', + 'last_changed': , + 'last_updated': , + 'state': '187', + }) +# --- +# name: test_sensors[sensor.sfr_box_dsl_training] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box DSL training', + 'options': list([ + 'idle', + 'g_994_training', + 'g_992_started', + 'g_922_channel_analysis', + 'g_992_message_exchange', + 'g_993_started', + 'g_993_channel_analysis', + 'g_993_message_exchange', + 'showtime', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_dsl_training', + 'last_changed': , + 'last_updated': , + 'state': 'showtime', + }) +# --- +# name: test_sensors[sensor.sfr_box_network_infrastructure] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box Network infrastructure', + 'options': list([ + 'adsl', + 'ftth', + 'gprs', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_network_infrastructure', + 'last_changed': , + 'last_updated': , + 'state': 'adsl', + }) +# --- +# name: test_sensors[sensor.sfr_box_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'SFR Box Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_temperature', + 'last_changed': , + 'last_updated': , + 'state': '27.56', + }) +# --- +# name: test_sensors[sensor.sfr_box_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'SFR Box Voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sfr_box_voltage', + 'last_changed': , + 'last_updated': , + 'state': '12251', + }) +# --- diff --git a/tests/components/sfr_box/test_sensor.py b/tests/components/sfr_box/test_sensor.py index cd8b868bac7e..4e2c9e33a748 100644 --- a/tests/components/sfr_box/test_sensor.py +++ b/tests/components/sfr_box/test_sensor.py @@ -1,18 +1,16 @@ """Test the SFR Box sensors.""" from collections.abc import Generator -from types import MappingProxyType from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.sfr_box import DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities -from .const import ATTR_DEFAULT_DISABLED, EXPECTED_ENTITIES - pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") @@ -23,37 +21,30 @@ def override_platforms() -> Generator[None, None, None]: yield -def _check_and_enable_disabled_entities( - entity_registry: er.EntityRegistry, expected_entities: MappingProxyType -) -> None: - """Ensure that the expected_entities are correctly disabled.""" - for expected_entity in expected_entities: - if expected_entity.get(ATTR_DEFAULT_DISABLED): - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry, f"Registry entry not found for {entity_id}" - assert registry_entry.disabled - assert registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - entity_registry.async_update_entity(entity_id, **{"disabled_by": None}) - - async def test_sensors( hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for SFR Box sensors.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - check_device_registry(device_registry, EXPECTED_ENTITIES["expected_device"]) + device_entry = device_registry.async_get_device({(DOMAIN, "e4:5d:51:00:11:22")}) + assert device_entry == snapshot - expected_entities = EXPECTED_ENTITIES[Platform.SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + for entity in entity_entries: + entity_registry.async_update_entity(entity.entity_id, **{"disabled_by": None}) - _check_and_enable_disabled_entities(entity_registry, expected_entities) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - check_entities(hass, entity_registry, expected_entities) + for entity in entity_entries: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) From 5c42261210230b3346e148f544e5af169c198ace Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Mar 2023 10:56:18 +0100 Subject: [PATCH 0427/1058] Refactor Command line binary sensor to inherit TemplateEntity (#81212) * Refactor binary sensor * Align --- .../components/command_line/binary_sensor.py | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index f4a3a29f29fd..2e1ddb7a9621 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -23,8 +23,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ( + TEMPLATE_ENTITY_BASE_SCHEMA, + TemplateEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS @@ -51,17 +55,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Command line Binary Sensor.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - name: str = config[CONF_NAME] + binary_sensor_config = vol.Schema( + TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA + )(config) + + name: str = config.get(CONF_NAME, DEFAULT_NAME) command: str = config[CONF_COMMAND] payload_off: str = config[CONF_PAYLOAD_OFF] payload_on: str = config[CONF_PAYLOAD_ON] @@ -73,9 +81,11 @@ def setup_platform( value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) - add_entities( + async_add_entities( [ CommandBinarySensor( + hass, + binary_sensor_config, data, name, device_class, @@ -89,11 +99,13 @@ def setup_platform( ) -class CommandBinarySensor(BinarySensorEntity): +class CommandBinarySensor(TemplateEntity, BinarySensorEntity): """Representation of a command line binary sensor.""" def __init__( self, + hass: HomeAssistant, + config: ConfigType, data: CommandSensorData, name: str, device_class: BinarySensorDeviceClass | None, @@ -103,22 +115,29 @@ class CommandBinarySensor(BinarySensorEntity): unique_id: str | None, ) -> None: """Initialize the Command line binary sensor.""" + TemplateEntity.__init__( + self, + hass, + config=config, + fallback_name=name, + unique_id=unique_id, + ) self.data = data - self._attr_name = name self._attr_device_class = device_class self._attr_is_on = None self._payload_on = payload_on self._payload_off = payload_off self._value_template = value_template - self._attr_unique_id = unique_id - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data and updates the state.""" - self.data.update() + await self.hass.async_add_executor_job(self.data.update) value = self.data.value if self._value_template is not None: - value = self._value_template.render_with_possible_json_value(value, False) + value = await self.hass.async_add_executor_job( + self._value_template.render_with_possible_json_value, value, False + ) if value == self._payload_on: self._attr_is_on = True elif value == self._payload_off: From 5c4f93fa3601f8a96506ec171eef61e1c09794b0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Mar 2023 10:57:30 +0100 Subject: [PATCH 0428/1058] Refactor Command line cover to inherit TemplateEntity (#81214) * Refactor cover * Remove not needed --- .../components/command_line/cover.py | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 8298201228f4..53773ae4e91b 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -20,8 +20,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ( + TEMPLATE_ENTITY_BASE_SCHEMA, + TemplateEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import call_shell_with_timeout, check_output_or_log @@ -47,15 +51,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up cover controlled by shell commands.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) devices: dict[str, Any] = config.get(CONF_COVERS, {}) covers = [] @@ -65,8 +69,14 @@ def setup_platform( if value_template is not None: value_template.hass = hass + cover_config = vol.Schema( + TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA + )(device_config) + covers.append( CommandCover( + hass, + cover_config, device_config.get(CONF_FRIENDLY_NAME, device_name), device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], @@ -82,14 +92,16 @@ def setup_platform( _LOGGER.error("No covers added") return - add_entities(covers) + async_add_entities(covers) -class CommandCover(CoverEntity): +class CommandCover(TemplateEntity, CoverEntity): """Representation a command line cover.""" def __init__( self, + hass: HomeAssistant, + config: ConfigType, name: str, command_open: str, command_close: str, @@ -100,7 +112,13 @@ class CommandCover(CoverEntity): unique_id: str | None, ) -> None: """Initialize the cover.""" - self._attr_name = name + TemplateEntity.__init__( + self, + hass, + config=config, + fallback_name=name, + unique_id=unique_id, + ) self._state: int | None = None self._command_open = command_open self._command_close = command_close @@ -108,7 +126,6 @@ class CommandCover(CoverEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout - self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) def _move_cover(self, command: str) -> bool: @@ -148,12 +165,14 @@ class CommandCover(CoverEntity): if TYPE_CHECKING: return None - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" if self._command_state: - payload = str(self._query_state()) + payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._value_template: - payload = self._value_template.render_with_possible_json_value(payload) + payload = await self.hass.async_add_executor_job( + self._value_template.render_with_possible_json_value, payload + ) self._state = int(payload) def open_cover(self, **kwargs: Any) -> None: From 7284af6a3ecdf1f92d92238b2ab4747c4fa6c312 Mon Sep 17 00:00:00 2001 From: David Poll Date: Mon, 13 Mar 2023 03:00:05 -0700 Subject: [PATCH 0429/1058] Add an in-memory-preloading loader for Jinja imports (#88850) * Adds a loader to enable jinja imports. * Switch to in-memory * Move loading custom_jinja off of the event loop * Raise TemplateNotFound if template doesn't exist * Fix docstring * Adds a service to reload custom jinja * Remove IO from test setup * Improve coverage and small refactor * Incorporate feedback and use .jinja extension * Check the loaded sources in test. * Incorporate PR feedback. * Update homeassistant/helpers/template.py Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- homeassistant/bootstrap.py | 2 + .../components/homeassistant/__init__.py | 25 ++++++-- .../components/homeassistant/services.yaml | 6 ++ homeassistant/helpers/template.py | 62 +++++++++++++++++++ tests/components/homeassistant/test_init.py | 18 ++++++ tests/helpers/test_template.py | 61 ++++++++++++++++++ .../custom_jinja/inner/inner_test.jinja | 5 ++ tests/testing_config/custom_jinja/test.jinja | 5 ++ 8 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 tests/testing_config/custom_jinja/inner/inner_test.jinja create mode 100644 tests/testing_config/custom_jinja/test.jinja diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 29772e865afd..9ba4e99a0823 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -31,6 +31,7 @@ from .helpers import ( entity_registry, issue_registry, recorder, + template, ) from .helpers.dispatcher import async_dispatcher_send from .helpers.typing import ConfigType @@ -244,6 +245,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: entity_registry.async_load(hass), issue_registry.async_load(hass), hass.async_add_executor_job(_cache_uname_processor), + template.async_load_custom_jinja(hass), ) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 5602fd6b59a8..4b033fd7119c 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -30,6 +30,7 @@ from homeassistant.helpers.service import ( async_extract_referenced_entity_ids, async_register_admin_service, ) +from homeassistant.helpers.template import async_load_custom_jinja from homeassistant.helpers.typing import ConfigType ATTR_ENTRY_ID = "entry_id" @@ -38,6 +39,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" +SERVICE_RELOAD_CUSTOM_JINJA = "reload_custom_jinja" SERVICE_CHECK_CONFIG = "check_config" SERVICE_UPDATE_ENTITY = "update_entity" SERVICE_SET_LOCATION = "set_location" @@ -258,6 +260,14 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), ) + async def async_handle_reload_jinja(call: ha.ServiceCall) -> None: + """Service handler to reload custom Jinja.""" + await async_load_custom_jinja(hass) + + async_register_admin_service( + hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA, async_handle_reload_jinja + ) + async def async_handle_reload_config_entry(call: ha.ServiceCall) -> None: """Service handler for reloading a config entry.""" reload_entries = set() @@ -288,8 +298,10 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no reload of YAML configurations for the domain that support it. Additionally, it also calls the `homeasssitant.reload_core_config` - service, as that reloads the core YAML configuration, and the - `frontend.reload_themes` service, as that reloads the themes. + service, as that reloads the core YAML configuration, the + `frontend.reload_themes` service that reloads the themes, and the + `homeassistant.reload_custom_jinja` service that reloads any custom + jinja into memory. We only do so, if there are no configuration errors. """ @@ -315,10 +327,11 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass.services.async_call( domain, service, context=call.context, blocking=True ) - for domain, service in { - ha.DOMAIN: SERVICE_RELOAD_CORE_CONFIG, - "frontend": "reload_themes", - }.items() + for domain, service in ( + (ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG), + ("frontend", "reload_themes"), + (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA), + ) ] await asyncio.gather(*tasks) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index da52ff50d2f5..20f23402a738 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -59,6 +59,12 @@ update_entity: target: entity: {} +reload_custom_jinja: + name: Reload custom Jinja2 templates + description: >- + Reload Jinja2 templates found in the custom_jinja folder in your config. + New values will be applied on the next render of the template. + reload_config_entry: name: Reload config entry description: Reload a config entry that matches a target. diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5205d51273fb..1c5d15801f8a 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -14,6 +14,7 @@ import json import logging import math from operator import attrgetter, contains +import pathlib import random import re import statistics @@ -73,6 +74,7 @@ from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException from . import area_registry, device_registry, entity_registry, location as loc_helper +from .singleton import singleton from .typing import TemplateVarsType # mypy: allow-untyped-defs, no-check-untyped-defs @@ -85,6 +87,7 @@ _RENDER_INFO = "template.render_info" _ENVIRONMENT = "template.environment" _ENVIRONMENT_LIMITED = "template.environment_limited" _ENVIRONMENT_STRICT = "template.environment_strict" +_HASS_LOADER = "template.hass_loader" _RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{|\{#") # Match "simple" ints and floats. -1.0, 1, +5, 5.0 @@ -120,6 +123,8 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 +MAX_CUSTOM_JINJA_SIZE = 5 * 1024 * 1024 + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -2056,6 +2061,60 @@ class LoggingUndefined(jinja2.Undefined): return super().__bool__() +async def async_load_custom_jinja(hass: HomeAssistant) -> None: + """Load all custom jinja files under 5MiB into memory.""" + return await hass.async_add_executor_job(_load_custom_jinja, hass) + + +def _load_custom_jinja(hass: HomeAssistant) -> None: + result = {} + jinja_path = hass.config.path("custom_jinja") + all_files = [ + item + for item in pathlib.Path(jinja_path).rglob("*.jinja") + if item.is_file() and item.stat().st_size <= MAX_CUSTOM_JINJA_SIZE + ] + for file in all_files: + content = file.read_text() + path = str(file.relative_to(jinja_path)) + result[path] = content + + _get_hass_loader(hass).sources = result + + +@singleton(_HASS_LOADER) +def _get_hass_loader(hass: HomeAssistant) -> HassLoader: + return HassLoader({}) + + +class HassLoader(jinja2.BaseLoader): + """An in-memory jinja loader that keeps track of templates that need to be reloaded.""" + + def __init__(self, sources: dict[str, str]) -> None: + """Initialize an empty HassLoader.""" + self._sources = sources + self._reload = 0 + + @property + def sources(self) -> dict[str, str]: + """Map filename to jinja source.""" + return self._sources + + @sources.setter + def sources(self, value: dict[str, str]) -> None: + self._sources = value + self._reload += 1 + + def get_source( + self, environment: jinja2.Environment, template: str + ) -> tuple[str, str | None, Callable[[], bool] | None]: + """Get in-memory sources.""" + if template not in self._sources: + raise jinja2.TemplateNotFound(template) + cur_reload = self._reload + return self._sources[template], template, lambda: cur_reload == self._reload + + class TemplateEnvironment(ImmutableSandboxedEnvironment): """The Home Assistant template environment.""" @@ -2159,6 +2218,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): if hass is None: return + # This environment has access to hass, attach its loader to enable imports. + self.loader = _get_hass_loader(hass) + # We mark these as a context functions to ensure they get # evaluated fresh with every execution, rather than executed # at compile time and the value stored. The context itself diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 8b982dc1c31e..4a0424169652 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.homeassistant import ( SERVICE_CHECK_CONFIG, SERVICE_RELOAD_ALL, SERVICE_RELOAD_CORE_CONFIG, + SERVICE_RELOAD_CUSTOM_JINJA, SERVICE_SET_LOCATION, ) from homeassistant.const import ( @@ -575,6 +576,21 @@ async def test_save_persistent_states(hass: HomeAssistant) -> None: assert mock_save.called +async def test_reload_custom_jinja(hass: HomeAssistant) -> None: + """Test we can call reload_custom_jinja.""" + await async_setup_component(hass, "homeassistant", {}) + with patch( + "homeassistant.components.homeassistant.async_load_custom_jinja", + return_value=None, + ) as mock_load_custom_jinja: + await hass.services.async_call( + "homeassistant", + SERVICE_RELOAD_CUSTOM_JINJA, + blocking=True, + ) + assert mock_load_custom_jinja.called + + async def test_reload_all( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -586,6 +602,7 @@ async def test_reload_all( notify = async_mock_service(hass, "notify", "reload") core_config = async_mock_service(hass, "homeassistant", "reload_core_config") themes = async_mock_service(hass, "frontend", "reload_themes") + jinja = async_mock_service(hass, "homeassistant", "reload_custom_jinja") with patch( "homeassistant.config.async_check_ha_config_file", @@ -632,3 +649,4 @@ async def test_reload_all( assert len(test2) == 1 assert len(core_config) == 1 assert len(themes) == 1 + assert len(jinja) == 1 diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 5122a4238ead..740040835c6b 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -243,6 +243,67 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: ) +async def test_import(hass: HomeAssistant) -> None: + """Test that imports work from the config/custom_jinja folder.""" + await template.async_load_custom_jinja(hass) + assert "test.jinja" in template._get_hass_loader(hass).sources + assert "inner/inner_test.jinja" in template._get_hass_loader(hass).sources + assert ( + template.Template( + """ + {% import 'test.jinja' as t %} + {{ t.test_macro() }} {{ t.test_variable }} + """, + hass, + ).async_render() + == "macro variable" + ) + + assert ( + template.Template( + """ + {% import 'inner/inner_test.jinja' as t %} + {{ t.test_macro() }} {{ t.test_variable }} + """, + hass, + ).async_render() + == "inner macro inner variable" + ) + + with pytest.raises(TemplateError): + template.Template( + """ + {% import 'notfound.jinja' as t %} + {{ t.test_macro() }} {{ t.test_variable }} + """, + hass, + ).async_render() + + +async def test_import_change(hass: HomeAssistant) -> None: + """Test that a change in HassLoader results in updated imports.""" + await template.async_load_custom_jinja(hass) + to_test = template.Template( + """ + {% import 'test.jinja' as t %} + {{ t.test_macro() }} {{ t.test_variable }} + """, + hass, + ) + assert to_test.async_render() == "macro variable" + + template._get_hass_loader(hass).sources = { + "test.jinja": """ + {% macro test_macro() -%} + macro2 + {%- endmacro %} + + {% set test_variable = "variable2" %} + """ + } + assert to_test.async_render() == "macro2 variable2" + + def test_loop_controls(hass: HomeAssistant) -> None: """Test that loop controls are enabled.""" assert ( diff --git a/tests/testing_config/custom_jinja/inner/inner_test.jinja b/tests/testing_config/custom_jinja/inner/inner_test.jinja new file mode 100644 index 000000000000..e0cf20be7627 --- /dev/null +++ b/tests/testing_config/custom_jinja/inner/inner_test.jinja @@ -0,0 +1,5 @@ +{% macro test_macro() -%} +inner macro +{%- endmacro %} + +{% set test_variable = "inner variable" %} \ No newline at end of file diff --git a/tests/testing_config/custom_jinja/test.jinja b/tests/testing_config/custom_jinja/test.jinja new file mode 100644 index 000000000000..44be0f3a57ac --- /dev/null +++ b/tests/testing_config/custom_jinja/test.jinja @@ -0,0 +1,5 @@ +{% macro test_macro() -%} +macro +{%- endmacro %} + +{% set test_variable = "variable" %} \ No newline at end of file From e73e88b9222c9e55722c6a209632f89a7d0890b6 Mon Sep 17 00:00:00 2001 From: cnico Date: Mon, 13 Mar 2023 11:16:45 +0100 Subject: [PATCH 0430/1058] Bump flipr-api to 1.5.0 (#89598) flipr api 1.5.0 to use the new cloud api --- 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 e7b9c8bf8142..73a0b3edb26d 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.4.4"] + "requirements": ["flipr-api==1.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4e25506c9f4e..1a19b090723e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -722,7 +722,7 @@ fixerio==1.0.0a0 fjaraskupan==2.2.0 # homeassistant.components.flipr -flipr-api==1.4.4 +flipr-api==1.5.0 # homeassistant.components.flux_led flux_led==0.28.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02b7f7959c1e..685653627e21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -550,7 +550,7 @@ fivem-api==0.1.2 fjaraskupan==2.2.0 # homeassistant.components.flipr -flipr-api==1.4.4 +flipr-api==1.5.0 # homeassistant.components.flux_led flux_led==0.28.35 From 78e8de9bd7cf631802c45680b435191d2cd0f265 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Mon, 13 Mar 2023 11:33:35 +0100 Subject: [PATCH 0431/1058] Add ESERA 1-Wire virtual integration (#89487) --- homeassistant/components/esera_onewire/__init__.py | 1 + homeassistant/components/esera_onewire/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/esera_onewire/__init__.py create mode 100644 homeassistant/components/esera_onewire/manifest.json diff --git a/homeassistant/components/esera_onewire/__init__.py b/homeassistant/components/esera_onewire/__init__.py new file mode 100644 index 000000000000..1adcf6cf63af --- /dev/null +++ b/homeassistant/components/esera_onewire/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: ESERA 1-Wire.""" diff --git a/homeassistant/components/esera_onewire/manifest.json b/homeassistant/components/esera_onewire/manifest.json new file mode 100644 index 000000000000..8d5e944c5c11 --- /dev/null +++ b/homeassistant/components/esera_onewire/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "esera_onewire", + "name": "ESERA 1-Wire", + "integration_type": "virtual", + "supported_by": "onewire" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9742af1edfca..4adde5b2449d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1473,6 +1473,11 @@ "config_flow": true, "iot_class": "local_push" }, + "esera_onewire": { + "name": "ESERA 1-Wire", + "integration_type": "virtual", + "supported_by": "onewire" + }, "esphome": { "name": "ESPHome", "integration_type": "device", From 6e10cd81ddc7ebf9f26f6ec044f64c7cb4766987 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 13 Mar 2023 11:43:41 +0100 Subject: [PATCH 0432/1058] Use repair issue when port enable fails in Reolink (#89591) * Reolink use repair issue for disabled ports * fix styling * Add port repair issue tests * Update homeassistant/components/reolink/strings.json Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- homeassistant/components/reolink/host.py | 33 +++++++++++-------- homeassistant/components/reolink/strings.json | 4 +++ tests/components/reolink/test_init.py | 23 ++++++++++++- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 9994afe79a82..9d54191aadda 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -109,23 +109,30 @@ class ReolinkHost: enable_rtsp=enable_rtsp, ) except ReolinkError: + ports = "" if enable_onvif: - _LOGGER.error( - "Failed to enable ONVIF on %s. " - "Set it to ON to receive notifications", - self._api.nvr_name, - ) + ports += "ONVIF " if enable_rtmp: - _LOGGER.error( - "Failed to enable RTMP on %s. Set it to ON", - self._api.nvr_name, - ) + ports += "RTMP " elif enable_rtsp: - _LOGGER.error( - "Failed to enable RTSP on %s. Set it to ON", - self._api.nvr_name, - ) + ports += "RTSP " + + ir.async_create_issue( + self._hass, + DOMAIN, + "enable_port", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="enable_port", + translation_placeholders={ + "name": self._api.nvr_name, + "ports": ports, + "info_link": "https://support.reolink.com/hc/en-us/articles/900004435763-How-to-Set-up-Reolink-Ports-Settings-via-Reolink-Client-New-Client-", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "enable_port") self._unique_id = format_mac(self._api.mac_address) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 3ab77d2b8f43..c86917b4de27 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -42,6 +42,10 @@ "https_webhook": { "title": "Reolink webhook URL uses HTTPS (SSL)", "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`" + }, + "enable_port": { + "title": "Reolink port not enabled", + "description": "Failed to automatically enable {ports}port(s) on {name}. Use the [Reolink client]({info_link}) to manually set it to ON" } }, "entity": { diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 035bfa6e5389..52bd2d8c5f85 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -86,7 +86,7 @@ async def test_entry_reloading( assert config_entry.title == "New Name" -async def test_http_no_repair_issue( +async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test no repairs issue is raised when http local url is used.""" @@ -99,6 +99,7 @@ async def test_http_no_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") not in issue_registry.issues + assert (const.DOMAIN, "enable_port") not in issue_registry.issues async def test_https_repair_issue( @@ -114,3 +115,23 @@ async def test_https_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") in issue_registry.issues + + +@pytest.mark.parametrize("protocol", ["rtsp", "rtmp"]) +async def test_port_repair_issue( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + protocol: str, +) -> None: + """Test repairs issue is raised when auto enable of ports fails.""" + reolink_connect.set_net_port = AsyncMock(side_effect=ReolinkError("Test error")) + reolink_connect.onvif_enabled = False + reolink_connect.rtsp_enabled = False + reolink_connect.rtmp_enabled = False + reolink_connect.protocol = protocol + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "enable_port") in issue_registry.issues From 40ed3be4a82505c71349f26080598d214af2659f Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Mon, 13 Mar 2023 11:57:49 +0100 Subject: [PATCH 0433/1058] Fix gtfs with 2023.3 (sqlachemy update) (#89175) --- homeassistant/components/gtfs/sensor.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 3a79d8d88a9f..6cf1a6d46040 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -342,12 +342,14 @@ def get_next_departure( origin_stop_time.departure_time LIMIT :limit """ - result = schedule.engine.execute( + result = schedule.engine.connect().execute( text(sql_query), - origin_station_id=start_station_id, - end_station_id=end_station_id, - today=now_date, - limit=limit, + { + "origin_station_id": start_station_id, + "end_station_id": end_station_id, + "today": now_date, + "limit": limit, + }, ) # Create lookup timetable for today and possibly tomorrow, taking into @@ -357,7 +359,8 @@ def get_next_departure( yesterday_start = today_start = tomorrow_start = None yesterday_last = today_last = "" - for row in result: + for row_cursor in result: + row = row_cursor._asdict() if row["yesterday"] == 1 and yesterday_date >= row["start_date"]: extras = {"day": "yesterday", "first": None, "last": False} if yesterday_start is None: @@ -800,7 +803,10 @@ class GTFSDepartureSensor(SensorEntity): @staticmethod def dict_for_table(resource: Any) -> dict: """Return a dictionary for the SQLAlchemy resource given.""" - return {col: getattr(resource, col) for col in resource.__table__.columns} + _dict = {} + for column in resource.__table__.columns: + _dict[column.name] = str(getattr(resource, column.name)) + return _dict def append_keys(self, resource: dict, prefix: str | None = None) -> None: """Properly format key val pairs to append to attributes.""" From 431a8d0047dd22f1c40a97db72cbf23efb844344 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 13 Mar 2023 12:16:52 +0100 Subject: [PATCH 0434/1058] Add support for dual lens cameras in Reolink (#89554) --- homeassistant/components/reolink/camera.py | 9 +++++++-- homeassistant/components/reolink/entity.py | 12 +++++++++--- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 13471df33925..a34f8c85d36c 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from reolink_aio.api import DUAL_LENS_MODELS + from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -25,7 +27,7 @@ async def async_setup_entry( host = reolink_data.host cameras = [] - for channel in host.api.channels: + for channel in host.api.stream_channels: streams = ["sub", "main", "snapshots"] if host.api.protocol in ["rtmp", "flv"]: streams.append("ext") @@ -56,7 +58,10 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): self._stream = stream - self._attr_name = self._stream + if self._host.api.model in DUAL_LENS_MODELS: + self._attr_name = f"{self._stream} lens {self._channel}" + else: + self._attr_name = self._stream self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{self._stream}" self._attr_entity_registry_enabled_default = stream == "sub" diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 3a962d099dfd..48652eac21a5 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import TypeVar +from reolink_aio.api import DUAL_LENS_MODELS + from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -75,12 +77,16 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): self._channel = channel + dev_ch = channel + if self._host.api.model in DUAL_LENS_MODELS: + dev_ch = 0 + if self._host.api.is_nvr: self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._host.unique_id}_ch{self._channel}")}, + identifiers={(DOMAIN, f"{self._host.unique_id}_ch{dev_ch}")}, via_device=(DOMAIN, self._host.unique_id), - name=self._host.api.camera_name(self._channel), - model=self._host.api.camera_model(self._channel), + name=self._host.api.camera_name(dev_ch), + model=self._host.api.camera_model(dev_ch), manufacturer=self._host.api.manufacturer, configuration_url=self._conf_url, ) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5cb7530ec8e7..35ce21ab7822 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.5.3"] + "requirements": ["reolink-aio==0.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a19b090723e..478db4056977 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.3 +reolink-aio==0.5.4 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 685653627e21..9122d05933ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1594,7 +1594,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.3 +reolink-aio==0.5.4 # homeassistant.components.python_script restrictedpython==6.0 From 429e52cf3db5e7f806c594e4d4b4f9917da20fc1 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Mon, 13 Mar 2023 19:40:09 +0800 Subject: [PATCH 0435/1058] Improve typing in climate.py (#89577) --- homeassistant/components/izone/climate.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 78fd87b8e2cb..3e19afcca26a 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -1,6 +1,7 @@ """Support for the iZone HVAC.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -246,7 +247,7 @@ class ControllerDevice(ClimateEntity): zone.async_schedule_update_ha_state() @property - def unique_id(self): + def unique_id(self) -> str: """Return the ID of the controller device.""" return self._controller.device_uid @@ -256,7 +257,7 @@ class ControllerDevice(ClimateEntity): return f"iZone Controller {self._controller.device_uid}" @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" return { "supply_temperature": show_temp( @@ -306,13 +307,13 @@ class ControllerDevice(ClimateEntity): @property @_return_on_connection_error(PRESET_NONE) - def preset_mode(self): + def preset_mode(self) -> str: """Eco mode is external air.""" return PRESET_ECO if self._controller.free_air else PRESET_NONE @property @_return_on_connection_error([PRESET_NONE]) - def preset_modes(self): + def preset_modes(self) -> list[str]: """Available preset modes, normal or eco.""" if self._controller.free_air_enabled: return [PRESET_NONE, PRESET_ECO] @@ -507,7 +508,7 @@ class ZoneDevice(ClimateEntity): return self._controller.available @property - def unique_id(self): + def unique_id(self) -> str: """Return the ID of the controller device.""" return f"{self._controller.unique_id}_z{self._zone.index + 1}" @@ -539,29 +540,29 @@ class ZoneDevice(ClimateEntity): return list(self._state_to_pizone) @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self._zone.temp_current @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self._zone.type != Zone.Type.AUTO: return None return self._zone.temp_setpoint @property - def target_temperature_step(self): + def target_temperature_step(self) -> float: """Return the supported step of target temperature.""" return 0.5 @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self._controller.min_temp @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self._controller.max_temp @@ -626,7 +627,7 @@ class ZoneDevice(ClimateEntity): return self._zone.index @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" return { "airflow_max": self._zone.airflow_max, From f3da95fb1f43329028e918dbea0a1e6dab34556c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Mar 2023 13:37:51 +0100 Subject: [PATCH 0436/1058] Use SnapshotAssertion in SFR binary sensor tests (#89624) --- tests/components/sfr_box/const.py | 10 --- .../sfr_box/snapshots/test_binary_sensor.ambr | 75 +++++++++++++++++++ .../components/sfr_box/test_binary_sensor.py | 21 ++++-- 3 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 tests/components/sfr_box/snapshots/test_binary_sensor.ambr diff --git a/tests/components/sfr_box/const.py b/tests/components/sfr_box/const.py index c3ed56e32a70..44a2ce4a575f 100644 --- a/tests/components/sfr_box/const.py +++ b/tests/components/sfr_box/const.py @@ -1,5 +1,4 @@ """Constants for SFR Box tests.""" -from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass from homeassistant.components.sensor import ATTR_OPTIONS, ATTR_STATE_CLASS from homeassistant.components.sfr_box.const import DOMAIN @@ -12,7 +11,6 @@ from homeassistant.const import ( ATTR_STATE, ATTR_SW_VERSION, ATTR_UNIT_OF_MEASUREMENT, - STATE_ON, STATE_UNKNOWN, Platform, ) @@ -33,14 +31,6 @@ EXPECTED_ENTITIES = { ATTR_NAME: "SFR Box", ATTR_SW_VERSION: "NB6VAC-MAIN-R4.0.44k", }, - Platform.BINARY_SENSOR: [ - { - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.CONNECTIVITY, - ATTR_ENTITY_ID: "binary_sensor.sfr_box_dsl_status", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_dsl_status", - }, - ], Platform.BUTTON: [ { ATTR_DEVICE_CLASS: ButtonDeviceClass.RESTART, diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..c3442c08e241 --- /dev/null +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_binary_sensors + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.0.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sfr_box', + 'e4:5d:51:00:11:22', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'NB6VAC-FXC-r0', + 'name': 'SFR Box', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'NB6VAC-MAIN-R4.0.44k', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors.1 + list([ + 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.sfr_box_dsl_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DSL status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_dsl_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[binary_sensor.sfr_box_dsl_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box DSL status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_dsl_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/sfr_box/test_binary_sensor.py b/tests/components/sfr_box/test_binary_sensor.py index 0aed381cff18..03e0677713b5 100644 --- a/tests/components/sfr_box/test_binary_sensor.py +++ b/tests/components/sfr_box/test_binary_sensor.py @@ -1,17 +1,15 @@ -"""Test the SFR Box sensors.""" +"""Test the SFR Box binary sensors.""" from collections.abc import Generator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities -from .const import EXPECTED_ENTITIES - pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") @@ -27,14 +25,21 @@ async def test_binary_sensors( config_entry: ConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for SFR Box binary sensors.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - check_device_registry(device_registry, EXPECTED_ENTITIES["expected_device"]) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = EXPECTED_ENTITIES[Platform.BINARY_SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + for entity in entity_entries: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) From a230732087f44ebfa1c7e950120b5385abfa1243 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 13:56:08 +0100 Subject: [PATCH 0437/1058] Correct naming of some otbr tests (#89631) --- tests/components/otbr/test_websocket_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 1c44091ae5d6..32c5ae19e077 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -154,7 +154,7 @@ async def test_create_network_no_entry( assert msg["error"]["code"] == "not_loaded" -async def test_get_info_fetch_fails_1( +async def test_create_network_fails_1( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry, @@ -180,7 +180,7 @@ async def test_get_info_fetch_fails_1( assert msg["error"]["code"] == "set_enabled_failed" -async def test_get_info_fetch_fails_2( +async def test_create_network_fails_2( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry, @@ -208,7 +208,7 @@ async def test_get_info_fetch_fails_2( assert msg["error"]["code"] == "create_active_dataset_failed" -async def test_get_info_fetch_fails_3( +async def test_create_network_fails_3( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry, From 3637d787cf5fe85146aa693a00813928e539211e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 14:01:28 +0100 Subject: [PATCH 0438/1058] Fix `intellifire` name property (#89632) Fix intellifire name property --- homeassistant/components/intellifire/entity.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/intellifire/entity.py b/homeassistant/components/intellifire/entity.py index 1e406aeb1198..6ef63f5347ca 100644 --- a/homeassistant/components/intellifire/entity.py +++ b/homeassistant/components/intellifire/entity.py @@ -11,6 +11,7 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): """Define a generic class for Intellifire entities.""" _attr_attribution = "Data provided by unpublished Intellifire API" + _attr_has_entity_name = True def __init__( self, @@ -20,9 +21,6 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): """Class initializer.""" super().__init__(coordinator=coordinator) self.entity_description = description - # Set the Display name the User will see - self._attr_name = description.name self._attr_unique_id = f"{description.key}_{coordinator.read_api.data.serial}" - self._attr_has_entity_name = True # Configure the Device Info self._attr_device_info = self.coordinator.device_info From 179cc4d7f78b685e7f8cdac7ed13ee85ae08ce43 Mon Sep 17 00:00:00 2001 From: anotherthomas Date: Mon, 13 Mar 2023 14:46:16 +0100 Subject: [PATCH 0439/1058] Improve warnings in mqtt light messages (#89552) * improved warnings in mqtt light messages. * fixed tests. --- .../components/mqtt/light/schema_json.py | 25 +++++++++++++------ tests/components/mqtt/test_light_json.py | 12 ++++++--- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 55b2f99d536b..e0b20436fe66 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -260,7 +260,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid RGB color value received for entity %s", self.entity_id + "Invalid RGB color value '%s' received for entity %s", + values, + self.entity_id, ) return @@ -272,7 +274,9 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid XY color value received for entity %s", self.entity_id + "Invalid XY color value '%s' received for entity %s", + values, + self.entity_id, ) return @@ -284,14 +288,18 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid HS color value received for entity %s", self.entity_id + "Invalid HS color value '%s' received for entity %s", + values, + self.entity_id, ) return else: color_mode: str = values["color_mode"] if not self._supports_color_mode(color_mode): _LOGGER.warning( - "Invalid color mode received for entity %s", self.entity_id + "Invalid color mode '%s' received for entity %s", + color_mode, + self.entity_id, ) return try: @@ -333,7 +341,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_xy_color = (x, y) except (KeyError, ValueError): _LOGGER.warning( - "Invalid or incomplete color value received for entity %s", + "Invalid or incomplete color value '%s' received for entity %s", + values, self.entity_id, ) @@ -378,7 +387,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except (TypeError, ValueError): _LOGGER.warning( - "Invalid brightness value received for entity %s", + "Invalid brightness value '%s' received for entity %s", + values["brightness"], self.entity_id, ) @@ -397,7 +407,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): pass except ValueError: _LOGGER.warning( - "Invalid color temp value received for entity %s", + "Invalid color temp value '%s' received for entity %s", + values["color_temp"], self.entity_id, ) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index be664d1c1f87..dc73d7e6d1ba 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -616,14 +616,17 @@ async def test_controlling_state_via_topic2( async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "color_mode":"col_temp"}' ) - assert "Invalid color mode received" in caplog.text + assert "Invalid color mode 'col_temp' received" in caplog.text caplog.clear() # Incomplete color async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "color_mode":"rgb"}' ) - assert "Invalid or incomplete color value received" in caplog.text + assert ( + "Invalid or incomplete color value '{'state': 'ON', 'color_mode': 'rgb'}' received" + in caplog.text + ) caplog.clear() # Invalid color @@ -632,7 +635,10 @@ async def test_controlling_state_via_topic2( "test_light_rgb", '{"state":"ON", "color_mode":"rgb", "color":{"r":64,"g":128,"b":"cow"}}', ) - assert "Invalid or incomplete color value received" in caplog.text + assert ( + "Invalid or incomplete color value '{'state': 'ON', 'color_mode': 'rgb', 'color': {'r': 64, 'g': 128, 'b': 'cow'}}' received" + in caplog.text + ) async def test_sending_mqtt_commands_and_optimistic( From 3aeda1792a326de0a37898895044133e51a25756 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 13 Mar 2023 15:00:50 +0100 Subject: [PATCH 0440/1058] Bump easyEnergy to v0.2.1 (#89630) --- 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 6b88dd84c892..fc0a4fd7739c 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==0.1.2"] + "requirements": ["easyenergy==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 478db4056977..e95acc4de442 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.1.2 +easyenergy==0.2.1 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9122d05933ae..d242e7a8df03 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,7 +493,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.1.2 +easyenergy==0.2.1 # homeassistant.components.elgato elgato==4.0.1 From cdfb43d4035724ab58c7d62a83abc76029876182 Mon Sep 17 00:00:00 2001 From: Thijs Walcarius Date: Mon, 13 Mar 2023 15:06:45 +0100 Subject: [PATCH 0441/1058] Address late review comments for frontier_silicon config flow (#89507) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: wlcrs --- .../frontier_silicon/config_flow.py | 62 +++++++++---------- .../components/frontier_silicon/strings.json | 2 - .../frontier_silicon/test_config_flow.py | 40 +++++++++++- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 5e9472de62e2..a3fbdb52c1c6 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -37,19 +37,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize flow.""" - - self._webfsapi_url: str | None = None - self._name: str | None = None - self._unique_id: str | None = None + _webfsapi_url: str async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Handle the import of legacy configuration.yaml entries.""" device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device" try: - self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) except FSConnectionError: return self.async_abort(reason="cannot_connect") except Exception as exception: # pylint: disable=broad-except @@ -57,9 +52,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="unknown") try: - afsapi = AFSAPI(self._webfsapi_url, import_info[CONF_PIN]) + afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN]) - self._unique_id = await afsapi.get_radio_id() + unique_id = await afsapi.get_radio_id() except FSConnectionError: return self.async_abort(reason="cannot_connect") except InvalidPinException: @@ -68,12 +63,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) return self.async_abort(reason="unknown") - await self.async_set_unique_id(self._unique_id, raise_on_progress=False) + await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() - self._name = import_info[CONF_NAME] or "Radio" - - return await self._create_entry(pin=import_info[CONF_PIN]) + return self.async_create_entry( + title=import_info[CONF_NAME] or "Radio", + data={ + CONF_WEBFSAPI_URL: webfsapi_url, + CONF_PIN: import_info[CONF_PIN], + }, + ) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -112,18 +111,21 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # try to login with default pin afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) - self._name = await afsapi.get_friendly_name() + name = await afsapi.get_friendly_name() except InvalidPinException: # Ask for a PIN return await self.async_step_device_config() - self.context["title_placeholders"] = {"name": self._name} + self.context["title_placeholders"] = {"name": name} - self._unique_id = await afsapi.get_radio_id() - await self.async_set_unique_id(self._unique_id) + unique_id = await afsapi.get_radio_id() + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return await self._create_entry() + return self.async_create_entry( + title=name, + data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: DEFAULT_PIN}, + ) async def async_step_device_config( self, user_input: dict[str, Any] | None = None @@ -132,7 +134,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): We ask for the PIN in this step. """ - assert self._webfsapi_url is not None if user_input is None: return self.async_show_form( @@ -144,7 +145,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) - self._name = await afsapi.get_friendly_name() + name = await afsapi.get_friendly_name() except FSConnectionError: errors["base"] = "cannot_connect" @@ -154,10 +155,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception(exception) errors["base"] = "unknown" else: - self._unique_id = await afsapi.get_radio_id() - await self.async_set_unique_id(self._unique_id) + unique_id = await afsapi.get_radio_id() + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return await self._create_entry(pin=user_input[CONF_PIN]) + return self.async_create_entry( + title=name, + data={ + CONF_WEBFSAPI_URL: self._webfsapi_url, + CONF_PIN: user_input[CONF_PIN], + }, + ) data_schema = self.add_suggested_values_to_schema( STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input @@ -167,12 +174,3 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) - - async def _create_entry(self, pin: str | None = None) -> FlowResult: - """Create the entry.""" - assert self._name is not None - assert self._webfsapi_url is not None - - data = {CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN} - - return self.async_create_entry(title=self._name, data=data) diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 85b0b6958afc..3a0a504761b8 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -1,9 +1,7 @@ { "config": { - "flow_title": "{name}", "step": { "user": { - "title": "Frontier Silicon Setup", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index a643b121c74d..6a61f0b61855 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -194,7 +194,10 @@ async def test_form_nondefault_pin( ], ) async def test_form_nondefault_pin_invalid( - hass: HomeAssistant, friendly_name_error: Exception, result_error: str + hass: HomeAssistant, + friendly_name_error: Exception, + result_error: str, + mock_setup_entry: AsyncMock, ) -> None: """Test we get the proper errors when trying to validate an user-provided PIN.""" result = await hass.config_entries.flow.async_init( @@ -232,6 +235,20 @@ async def test_form_nondefault_pin_invalid( assert result2["step_id"] == "device_config" assert result3["errors"] == {"base": result_error} + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_PIN: "4321"}, + ) + await hass.async_block_till_done() + + assert result4["type"] == FlowResultType.CREATE_ENTRY + assert result4["title"] == "Name of the device" + assert result4["data"] == { + "webfsapi_url": "http://1.1.1.1:80/webfsapi", + "pin": "4321", + } + mock_setup_entry.assert_called_once() + @pytest.mark.parametrize( ("webfsapi_endpoint_error", "result_error"), @@ -241,9 +258,12 @@ async def test_form_nondefault_pin_invalid( ], ) async def test_invalid_device_url( - hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_error: str + hass: HomeAssistant, + webfsapi_endpoint_error: Exception, + result_error: str, + mock_setup_entry: AsyncMock, ) -> None: - """Test we get the form.""" + """Test flow when the user provides an invalid device IP/hostname.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -264,3 +284,17 @@ async def test_invalid_device_url( assert result2["type"] == FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": result_error} + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Name of the device" + assert result3["data"] == { + "webfsapi_url": "http://1.1.1.1:80/webfsapi", + "pin": "1234", + } + mock_setup_entry.assert_called_once() From 15506da332122bbdf627ffcbd69ce6e2e35f8b1a Mon Sep 17 00:00:00 2001 From: tomrennen Date: Mon, 13 Mar 2023 15:15:13 +0100 Subject: [PATCH 0442/1058] Improved "ON" state check for `Use room sensor for cooling` (#89634) --- homeassistant/components/nibe_heatpump/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 9c7d8641b6e2..a68aabacf4b4 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -139,7 +139,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): mode = HVACMode.OFF if _get_value(self._coil_use_room_sensor) == "ON": - if _get_value(self._coil_cooling_with_room_sensor) == "ON": + if _get_value(self._coil_cooling_with_room_sensor) != "OFF": mode = HVACMode.HEAT_COOL else: mode = HVACMode.HEAT From 07b25939a287e7ed20d3648b8a1f4674cc9f25d6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Mar 2023 15:23:00 +0100 Subject: [PATCH 0443/1058] Use SnapshotAssertion in SFR button tests (#89633) --- tests/components/sfr_box/__init__.py | 54 ------------- tests/components/sfr_box/const.py | 42 ----------- .../sfr_box/snapshots/test_button.ambr | 75 +++++++++++++++++++ tests/components/sfr_box/test_button.py | 25 +++++-- 4 files changed, 93 insertions(+), 103 deletions(-) delete mode 100644 tests/components/sfr_box/const.py create mode 100644 tests/components/sfr_box/snapshots/test_button.ambr diff --git a/tests/components/sfr_box/__init__.py b/tests/components/sfr_box/__init__.py index 651c419f4e9f..52d911ef832e 100644 --- a/tests/components/sfr_box/__init__.py +++ b/tests/components/sfr_box/__init__.py @@ -1,55 +1 @@ """Tests for the SFR Box integration.""" -from __future__ import annotations - -from types import MappingProxyType - -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_IDENTIFIERS, - ATTR_MODEL, - ATTR_NAME, - ATTR_STATE, - ATTR_SW_VERSION, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry -from homeassistant.helpers.entity_registry import EntityRegistry - -from .const import ATTR_UNIQUE_ID, FIXED_ATTRIBUTES - - -def check_device_registry( - device_registry: DeviceRegistry, expected_device: MappingProxyType -) -> None: - """Ensure that the expected_device is correctly registered.""" - assert len(device_registry.devices) == 1 - registry_entry = device_registry.async_get_device(expected_device[ATTR_IDENTIFIERS]) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device[ATTR_IDENTIFIERS] - assert registry_entry.name == expected_device[ATTR_NAME] - assert registry_entry.model == expected_device[ATTR_MODEL] - assert registry_entry.sw_version == expected_device[ATTR_SW_VERSION] - - -def check_entities( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state, f"Expected valid state for {entity_id}, got {state}" - assert state.state == expected_entity[ATTR_STATE], ( - f"Expected state {expected_entity[ATTR_STATE]}, got {state.state} for" - f" {entity_id}" - ) - for attr in FIXED_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr), ( - f"Expected attribute {attr} == {expected_entity.get(attr)}, got" - f" {state.attributes.get(attr)} for {entity_id}" - ) diff --git a/tests/components/sfr_box/const.py b/tests/components/sfr_box/const.py deleted file mode 100644 index 44a2ce4a575f..000000000000 --- a/tests/components/sfr_box/const.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Constants for SFR Box tests.""" -from homeassistant.components.button import ButtonDeviceClass -from homeassistant.components.sensor import ATTR_OPTIONS, ATTR_STATE_CLASS -from homeassistant.components.sfr_box.const import DOMAIN -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_IDENTIFIERS, - ATTR_MODEL, - ATTR_NAME, - ATTR_STATE, - ATTR_SW_VERSION, - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNKNOWN, - Platform, -) - -ATTR_DEFAULT_DISABLED = "default_disabled" -ATTR_UNIQUE_ID = "unique_id" -FIXED_ATTRIBUTES = ( - ATTR_DEVICE_CLASS, - ATTR_OPTIONS, - ATTR_STATE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, -) - -EXPECTED_ENTITIES = { - "expected_device": { - ATTR_IDENTIFIERS: {(DOMAIN, "e4:5d:51:00:11:22")}, - ATTR_MODEL: "NB6VAC-FXC-r0", - ATTR_NAME: "SFR Box", - ATTR_SW_VERSION: "NB6VAC-MAIN-R4.0.44k", - }, - Platform.BUTTON: [ - { - ATTR_DEVICE_CLASS: ButtonDeviceClass.RESTART, - ATTR_ENTITY_ID: "button.sfr_box_reboot", - ATTR_STATE: STATE_UNKNOWN, - ATTR_UNIQUE_ID: "e4:5d:51:00:11:22_system_reboot", - }, - ], -} diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr new file mode 100644 index 000000000000..a7ce9334e1b1 --- /dev/null +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -0,0 +1,75 @@ +# serializer version: 1 +# name: test_buttons + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.0.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sfr_box', + 'e4:5d:51:00:11:22', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'NB6VAC-FXC-r0', + 'name': 'SFR Box', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'NB6VAC-MAIN-R4.0.44k', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons.1 + list([ + 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.sfr_box_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_system_reboot', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[button.sfr_box_reboot] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'SFR Box Reboot', + }), + 'context': , + 'entity_id': 'button.sfr_box_reboot', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sfr_box/test_button.py b/tests/components/sfr_box/test_button.py index 22666505cfaa..d1bb06fc79c7 100644 --- a/tests/components/sfr_box/test_button.py +++ b/tests/components/sfr_box/test_button.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from sfrbox_api.exceptions import SFRBoxError +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntry @@ -12,9 +13,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import check_device_registry, check_entities -from .const import EXPECTED_ENTITIES - pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") @@ -32,17 +30,30 @@ async def test_buttons( config_entry_with_auth: ConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for SFR Box buttons.""" await hass.config_entries.async_setup(config_entry_with_auth.entry_id) await hass.async_block_till_done() - check_device_registry(device_registry, EXPECTED_ENTITIES["expected_device"]) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_with_auth.entry_id + ) + assert device_entries == snapshot - expected_entities = EXPECTED_ENTITIES[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry_with_auth.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + for entity in entity_entries: + assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +async def test_reboot(hass: HomeAssistant, config_entry_with_auth: ConfigEntry) -> None: + """Test for SFR Box reboot button.""" + await hass.config_entries.async_setup(config_entry_with_auth.entry_id) + await hass.async_block_till_done() # Reboot success service_data = {ATTR_ENTITY_ID: "button.sfr_box_reboot"} From 11e21378b17c82c33251afabb86a6b7c74d1e0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 13 Mar 2023 15:39:49 +0100 Subject: [PATCH 0444/1058] Add sensors for supervisor host (#89461) Co-authored-by: Franck Nijhof --- homeassistant/components/hassio/__init__.py | 19 +++++++ homeassistant/components/hassio/const.py | 2 + homeassistant/components/hassio/entity.py | 27 +++++++++ homeassistant/components/hassio/sensor.py | 63 ++++++++++++++++++++- tests/components/hassio/test_diagnostics.py | 3 +- tests/components/hassio/test_init.py | 8 +-- tests/components/hassio/test_sensor.py | 13 +++-- 7 files changed, 122 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 0f17c0b52725..25482ddde95a 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -69,6 +69,7 @@ from .const import ( ATTR_VERSION, DATA_KEY_ADDONS, DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, DOMAIN, @@ -668,6 +669,22 @@ def async_register_os_in_dev_reg( dev_reg.async_get_or_create(config_entry_id=entry_id, **params) +@callback +def async_register_host_in_dev_reg( + entry_id: str, + dev_reg: dr.DeviceRegistry, +) -> None: + """Register host in the device registry.""" + params = DeviceInfo( + identifiers={(DOMAIN, "host")}, + manufacturer="Home Assistant", + model=SupervisorEntityModel.HOST, + name="Home Assistant Host", + entry_type=dr.DeviceEntryType.SERVICE, + ) + dev_reg.async_get_or_create(config_entry_id=entry_id, **params) + + @callback def async_register_core_in_dev_reg( entry_id: str, @@ -777,6 +794,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): **supervisor_info, **get_supervisor_stats(self.hass), } + new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {} # If this is the initial refresh, register all addons and return the dict if not self.data: @@ -789,6 +807,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): async_register_supervisor_in_dev_reg( self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR] ) + async_register_host_in_dev_reg(self.entry_id, self.dev_reg) if self.is_hass_os: async_register_os_in_dev_reg( self.entry_id, self.dev_reg, new_data[DATA_KEY_OS] diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 2710e146540d..cc9c58a3d27e 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -68,6 +68,7 @@ DATA_KEY_ADDONS = "addons" DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_CORE = "core" +DATA_KEY_HOST = "host" class SupervisorEntityModel(str, Enum): @@ -77,3 +78,4 @@ class SupervisorEntityModel(str, Enum): OS = "Home Assistant Operating System" CORE = "Home Assistant Core" SUPERVIOSR = "Home Assistant Supervisor" + HOST = "Home Assistant Host" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index dfa89ae911ab..3a6a5a9f7c30 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -11,6 +11,7 @@ from .const import ( ATTR_SLUG, DATA_KEY_ADDONS, DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, ) @@ -71,6 +72,32 @@ class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): ) +class HassioHostEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): + """Base Entity for Hass.io host.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HassioDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"home_assistant_host_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "host")}) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and DATA_KEY_HOST in self.coordinator.data + and self.entity_description.key in self.coordinator.data[DATA_KEY_HOST] + ) + + class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Base Entity for Supervisor.""" diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index a5b0b3a725ff..b9a97adcbc2c 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -2,12 +2,13 @@ from __future__ import annotations from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,12 +20,14 @@ from .const import ( ATTR_VERSION_LATEST, DATA_KEY_ADDONS, DATA_KEY_CORE, + DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, ) from .entity import ( HassioAddonEntity, HassioCoreEntity, + HassioHostEntity, HassioOSEntity, HassioSupervisorEntity, ) @@ -66,6 +69,45 @@ CORE_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS SUPERVISOR_ENTITY_DESCRIPTIONS = STATS_ENTITY_DESCRIPTIONS +HOST_ENTITY_DESCRIPTIONS = ( + SensorEntityDescription( + entity_registry_enabled_default=False, + key="agent_version", + name="OS Agent version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="apparmor_version", + name="Apparmor version", + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_total", + name="Disk total", + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_used", + name="Disk used", + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + SensorEntityDescription( + entity_registry_enabled_default=False, + key="disk_free", + name="Disk free", + native_unit_of_measurement=UnitOfInformation.GIGABYTES, + device_class=SensorDeviceClass.DATA_SIZE, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -76,7 +118,7 @@ async def async_setup_entry( coordinator = hass.data[ADDONS_COORDINATOR] entities: list[ - HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor + HassioOSSensor | HassioAddonSensor | CoreSensor | SupervisorSensor | HostSensor ] = [] for addon in coordinator.data[DATA_KEY_ADDONS].values(): @@ -105,6 +147,14 @@ async def async_setup_entry( ) ) + for entity_description in HOST_ENTITY_DESCRIPTIONS: + entities.append( + HostSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + ) + if coordinator.is_hass_os: for entity_description in OS_ENTITY_DESCRIPTIONS: entities.append( @@ -153,3 +203,12 @@ class SupervisorSensor(HassioSupervisorEntity, SensorEntity): def native_value(self) -> str: """Return native value of entity.""" return self.coordinator.data[DATA_KEY_SUPERVISOR][self.entity_description.key] + + +class HostSensor(HassioHostEntity, SensorEntity): + """Sensor to track a host attribute.""" + + @property + def native_value(self) -> str: + """Return native value of entity.""" + return self.coordinator.data[DATA_KEY_HOST][self.entity_description.key] diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index b3d47e93afd6..6b0dae170c69 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -211,5 +211,6 @@ async def test_diagnostics( assert "core" in diagnostics["coordinator_data"] assert "supervisor" in diagnostics["coordinator_data"] assert "os" in diagnostics["coordinator_data"] + assert "host" in diagnostics["coordinator_data"] - assert len(diagnostics["devices"]) == 5 + assert len(diagnostics["devices"]) == 6 diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index a752fe1b677e..ead65d812927 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -678,7 +678,7 @@ async def test_device_registry_calls(hass: HomeAssistant) -> None: config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert len(dev_reg.devices) == 5 + assert len(dev_reg.devices) == 6 supervisor_mock_data = { "version": "1.0.0", @@ -709,11 +709,11 @@ async def test_device_registry_calls(hass: HomeAssistant) -> None: ): async_fire_time_changed(hass, dt_util.now() + timedelta(hours=1)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 4 + assert len(dev_reg.devices) == 5 async_fire_time_changed(hass, dt_util.now() + timedelta(hours=2)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 4 + assert len(dev_reg.devices) == 5 supervisor_mock_data = { "version": "1.0.0", @@ -763,7 +763,7 @@ async def test_device_registry_calls(hass: HomeAssistant) -> None: ): async_fire_time_changed(hass, dt_util.now() + timedelta(hours=3)) await hass.async_block_till_done() - assert len(dev_reg.devices) == 4 + assert len(dev_reg.devices) == 5 async def test_coordinator_updates( diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 99b1db2a99b3..d33c66973219 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -44,12 +44,10 @@ def mock_all(aioclient_mock, request): json={ "result": "ok", "data": { - "result": "ok", - "data": { - "chassis": "vm", - "operating_system": "Debian GNU/Linux 10 (buster)", - "kernel": "4.19.0-6-amd64", - }, + "agent_version": "1.0.0", + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", }, }, ) @@ -179,6 +177,9 @@ def mock_all(aioclient_mock, request): [ ("sensor.home_assistant_operating_system_version", "1.0.0"), ("sensor.home_assistant_operating_system_newest_version", "1.0.0"), + ("sensor.home_assistant_host_os_agent_version", "1.0.0"), + ("sensor.home_assistant_core_cpu_percent", "0.99"), + ("sensor.home_assistant_supervisor_cpu_percent", "0.99"), ("sensor.test_version", "2.0.0"), ("sensor.test_newest_version", "2.0.1"), ("sensor.test2_version", "3.1.0"), From 0f2abe7f252cb5c74ec9c43c7f86e247f4baff54 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 15:52:36 +0100 Subject: [PATCH 0445/1058] Bump python-otbr-api to 1.0.9 (#89637) --- 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 7efe5fefc3fd..2590e92210f8 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==1.0.8"] + "requirements": ["python-otbr-api==1.0.9"] } diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 5fcb287796f7..3d61315f3d17 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==1.0.8", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==1.0.9", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e95acc4de442..7c1f493071fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2097,7 +2097,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.8 +python-otbr-api==1.0.9 # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d242e7a8df03..915a14476d2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1499,7 +1499,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.8 +python-otbr-api==1.0.9 # homeassistant.components.picnic python-picnic-api==1.1.0 From 8a0522ca2a0802d6eed114a9529a3baaed699944 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 15:59:22 +0100 Subject: [PATCH 0446/1058] Include extended address in thread discovery data (#89640) --- homeassistant/components/thread/discovery.py | 3 +++ tests/components/thread/test_discovery.py | 4 ++++ tests/components/thread/test_websocket_api.py | 2 ++ 3 files changed, 9 insertions(+) diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index b2373ff98258..7dce5a429d86 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -32,6 +32,7 @@ class ThreadRouterDiscoveryData: addresses: list[str] | None brand: str | None + extended_address: str | None extended_pan_id: str | None model_name: str | None network_name: str | None @@ -55,6 +56,7 @@ def async_discovery_data_from_service( except UnicodeDecodeError: return None + ext_addr = service.properties.get(b"xa") ext_pan_id = service.properties.get(b"xp") network_name = try_decode(service.properties.get(b"nn")) model_name = try_decode(service.properties.get(b"mn")) @@ -78,6 +80,7 @@ def async_discovery_data_from_service( return ThreadRouterDiscoveryData( addresses=service.parsed_addresses(), brand=brand, + extended_address=ext_addr.hex() if ext_addr is not None else None, extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None, model_name=model_name, network_name=network_name, diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index e832f18c4e68..84fe4c309746 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -73,6 +73,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], brand="homeassistant", + extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", @@ -98,6 +99,7 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.124"], brand="google", + extended_address="f6a99b425a67abed", extended_pan_id="9e75e256f61409a3", model_name="Google Nest Hub", network_name="NEST-PAN-E1AF", @@ -175,6 +177,7 @@ async def test_discover_routers_unconfigured( discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], brand="homeassistant", + extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", @@ -219,6 +222,7 @@ async def test_discover_routers_bad_data( discovery.ThreadRouterDiscoveryData( addresses=["192.168.0.115"], brand=None, + extended_address="aeeb2f594b570bbf", extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 0f3a2ff76548..f8f09b0b8cca 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -236,6 +236,7 @@ async def test_discover_routers( "data": { "addresses": ["192.168.0.115"], "brand": "homeassistant", + "extended_address": "aeeb2f594b570bbf", "extended_pan_id": "e60fc7c186212ce5", "model_name": "OpenThreadBorderRouter", "network_name": "OpenThread HC", @@ -264,6 +265,7 @@ async def test_discover_routers( "data": { "addresses": ["192.168.0.124"], "brand": "google", + "extended_address": "f6a99b425a67abed", "extended_pan_id": "9e75e256f61409a3", "model_name": "Google Nest Hub", "network_name": "NEST-PAN-E1AF", From a7396af4bb8b5684727878eadd43676f481b583b Mon Sep 17 00:00:00 2001 From: Nick Borgers Date: Mon, 13 Mar 2023 11:06:29 -0400 Subject: [PATCH 0447/1058] Bump pybravia to 0.3.2 (#89635) --- homeassistant/components/braviatv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index 295a56b32443..c5b42e73beef 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pybravia"], - "requirements": ["pybravia==0.3.1"], + "requirements": ["pybravia==0.3.2"], "ssdp": [ { "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", diff --git a/requirements_all.txt b/requirements_all.txt index 7c1f493071fd..26ccf0efabc3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1531,7 +1531,7 @@ pyblackbird==0.5 pybotvac==0.0.23 # homeassistant.components.braviatv -pybravia==0.3.1 +pybravia==0.3.2 # homeassistant.components.nissan_leaf pycarwings2==2.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 915a14476d2e..6ea71ed0b268 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1119,7 +1119,7 @@ pyblackbird==0.5 pybotvac==0.0.23 # homeassistant.components.braviatv -pybravia==0.3.1 +pybravia==0.3.2 # homeassistant.components.cloudflare pycfdns==2.0.1 From 02389960ce62342fdfe7a213895c5e28dd92466b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Mar 2023 17:23:25 +0100 Subject: [PATCH 0448/1058] Refactor Command line sensor to inherit TemplateSensor (#81222) * Refactor sensor * Remove not needed * block until done * reset test * test sensor * Add time --- .../components/command_line/sensor.py | 68 ++++++++++++++----- tests/components/command_line/test_sensor.py | 22 +++++- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 5dbbbf88e581..24224c12cac8 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -8,9 +8,16 @@ import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA, + STATE_CLASSES_SCHEMA, + SensorEntity, +) from homeassistant.const import ( CONF_COMMAND, + CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -21,8 +28,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ( + TEMPLATE_SENSOR_BASE_SCHEMA, + TemplateSensor, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import check_output_or_log @@ -45,19 +56,25 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, } ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Command Sensor.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + + sensor_config = vol.Schema( + TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA + )(config) name: str = config[CONF_NAME] command: str = config[CONF_COMMAND] @@ -70,17 +87,30 @@ def setup_platform( json_attributes: list[str] | None = config.get(CONF_JSON_ATTRIBUTES) data = CommandSensorData(hass, command, command_timeout) - add_entities( - [CommandSensor(data, name, unit, value_template, json_attributes, unique_id)], + async_add_entities( + [ + CommandSensor( + hass, + sensor_config, + data, + name, + unit, + value_template, + json_attributes, + unique_id, + ) + ], True, ) -class CommandSensor(SensorEntity): +class CommandSensor(TemplateSensor, SensorEntity): """Representation of a sensor that is using shell commands.""" def __init__( self, + hass: HomeAssistant, + config: ConfigType, data: CommandSensorData, name: str, unit_of_measurement: str | None, @@ -89,18 +119,22 @@ class CommandSensor(SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" + TemplateSensor.__init__( + self, + hass, + config=config, + fallback_name=name, + unique_id=unique_id, + ) self.data = data self._attr_extra_state_attributes = {} self._json_attributes = json_attributes - self._attr_name = name self._attr_native_value = None - self._attr_native_unit_of_measurement = unit_of_measurement self._value_template = value_template - self._attr_unique_id = unique_id - def update(self) -> None: + async def async_update(self) -> None: """Get the latest data and updates the state.""" - self.data.update() + await self.hass.async_add_executor_job(self.data.update) value = self.data.value if self._json_attributes: @@ -124,10 +158,10 @@ class CommandSensor(SensorEntity): if value is None: value = STATE_UNKNOWN elif self._value_template is not None: - self._attr_native_value = ( - self._value_template.render_with_possible_json_value( - value, STATE_UNKNOWN - ) + self._attr_native_value = await self.hass.async_add_executor_job( + self._value_template.render_with_possible_json_value, + value, + STATE_UNKNOWN, ) else: self._attr_native_value = value diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index f7de3b339442..5aab14225f19 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the Command line sensor platform.""" from __future__ import annotations +from datetime import timedelta from typing import Any from unittest.mock import patch @@ -10,6 +11,9 @@ from homeassistant import setup from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt + +from tests.common import async_fire_time_changed async def setup_test_entities(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: @@ -67,6 +71,14 @@ async def test_template_render(hass: HomeAssistant) -> None: "command": "echo {{ states.sensor.input_sensor.state }}", }, ) + + # Give time for template to load + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "sensor_value" @@ -86,7 +98,15 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: }, ) - check_output.assert_called_once_with( + # Give time for template to load + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done() + + assert len(check_output.mock_calls) == 2 + check_output.assert_called_with( 'echo "sensor_value" "3 4"', shell=True, # nosec # shell by design timeout=15, From 0457bb2717c6ef61be1c975cb98a96f596fb474a Mon Sep 17 00:00:00 2001 From: David Poll Date: Mon, 13 Mar 2023 10:20:33 -0700 Subject: [PATCH 0449/1058] Add is_hidden_entity test for Jinja templates (#89011) --- homeassistant/helpers/template.py | 10 ++++++++++ tests/helpers/test_template.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 1c5d15801f8a..8f68c7af3787 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1442,6 +1442,13 @@ def distance(hass, *args): ) +def is_hidden_entity(hass: HomeAssistant, entity_id: str) -> bool: + """Test if an entity is hidden.""" + entity_reg = entity_registry.async_get(hass) + entry = entity_reg.async_get(entity_id) + return entry is not None and entry.hidden + + def is_state(hass: HomeAssistant, entity_id: str, state: str | list[str]) -> bool: """Test if a state is a specific value.""" state_obj = _get_state(hass, entity_id) @@ -2266,6 +2273,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = pass_context(self.globals["area_devices"]) + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) + self.tests["is_hidden_entity"] = pass_context(self.globals["is_hidden_entity"]) + self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = pass_context( self.globals["integration_entities"] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 740040835c6b..750602c9d6c9 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1443,6 +1443,26 @@ def test_if_state_exists(hass: HomeAssistant) -> None: assert tpl.async_render() == "exists" +def test_is_hidden_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test is_hidden_entity method.""" + hidden_entity = entity_registry.async_get_or_create( + "sensor", "mock", "hidden", hidden_by=er.RegistryEntryHider.USER + ) + visible_entity = entity_registry.async_get_or_create("sensor", "mock", "visible") + assert template.Template( + f"{{{{ is_hidden_entity('{hidden_entity.entity_id}') }}}}", + hass, + ).async_render() + + assert not template.Template( + f"{{{{ is_hidden_entity('{visible_entity.entity_id}') }}}}", + hass, + ).async_render() + + def test_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" hass.states.async_set("test.object", "available") From d54259f9ac3f72fda66de8e2268ae9697241fa4f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 13 Mar 2023 18:47:00 +0100 Subject: [PATCH 0450/1058] Bump reolink-aio to 0.5.5 (#89646) --- 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 35ce21ab7822..1f776d13721d 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.5.4"] + "requirements": ["reolink-aio==0.5.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 26ccf0efabc3..34148078091a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.4 +reolink-aio==0.5.5 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ea71ed0b268..1ad038042d74 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1594,7 +1594,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.4 +reolink-aio==0.5.5 # homeassistant.components.python_script restrictedpython==6.0 From d422b0dcc22a01cb4c8cf05eaad573741a50aee5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 13 Mar 2023 19:09:09 +0100 Subject: [PATCH 0451/1058] Make OTBR add newly created dataset to thread credential store (#89645) --- homeassistant/components/otbr/__init__.py | 5 +- .../components/otbr/websocket_api.py | 12 +++ tests/components/otbr/test_websocket_api.py | 73 ++++++++++++++++--- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index ca977e774f39..b1a9999f467b 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -60,6 +60,7 @@ class OTBRData: url: str api: python_otbr_api.OTBR + dataset_source: str @_handle_otbr_error async def set_enabled(self, enabled: bool) -> None: @@ -137,7 +138,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Open Thread Border Router config entry.""" api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10) - otbrdata = OTBRData(entry.data["url"], api) + otbrdata = OTBRData(entry.data["url"], api, entry.title) try: dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( @@ -148,7 +149,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady("Unable to connect") from err if dataset_tlvs: _warn_on_default_network_settings(hass, entry, dataset_tlvs) - await async_add_dataset(hass, entry.title, dataset_tlvs.hex()) + await async_add_dataset(hass, otbrdata.dataset_source, dataset_tlvs.hex()) hass.data[DOMAIN] = otbrdata diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 506a8cad1b79..8ea993362398 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING import python_otbr_api from homeassistant.components import websocket_api +from homeassistant.components.thread import async_add_dataset from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -96,6 +97,17 @@ async def websocket_create_network( connection.send_error(msg["id"], "set_enabled_failed", str(exc)) return + try: + dataset_tlvs = await data.get_active_dataset_tlvs() + except HomeAssistantError as exc: + connection.send_error(msg["id"], "get_active_dataset_tlvs_failed", str(exc)) + return + if not dataset_tlvs: + connection.send_error(msg["id"], "get_active_dataset_tlvs_empty", "") + return + + await async_add_dataset(hass, data.dataset_source, dataset_tlvs.hex()) + connection.send_result(msg["id"]) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 32c5ae19e077..087b5bb4865c 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -7,7 +7,7 @@ import python_otbr_api from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import BASE_URL +from . import BASE_URL, DATASET_CH16 from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -27,13 +27,7 @@ async def test_get_info( ) -> None: """Test async_get_info.""" - mock_response = ( - "0E080000000000010000000300001035060004001FFFE00208F642646DA209B1C00708FDF57B5A" - "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" - "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" - ) - - aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text=mock_response) + aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text=DATASET_CH16.hex()) await websocket_client.send_json( { @@ -47,7 +41,7 @@ async def test_get_info( assert msg["success"] assert msg["result"] == { "url": BASE_URL, - "active_dataset_tlvs": mock_response.lower(), + "active_dataset_tlvs": DATASET_CH16.hex().lower(), } @@ -110,7 +104,11 @@ async def test_create_network( "python_otbr_api.OTBR.create_active_dataset" ) as create_dataset_mock, patch( "python_otbr_api.OTBR.set_enabled" - ) as set_enabled_mock: + ) as set_enabled_mock, patch( + "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: await websocket_client.send_json( { "id": 5, @@ -131,6 +129,8 @@ async def test_create_network( assert len(set_enabled_mock.mock_calls) == 2 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("Open Thread Border Router", DATASET_CH16.hex()) async def test_create_network_no_entry( @@ -236,6 +236,59 @@ async def test_create_network_fails_3( assert msg["error"]["code"] == "set_enabled_failed" +async def test_create_network_fails_4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch("python_otbr_api.OTBR.set_enabled"), patch( + "python_otbr_api.OTBR.create_active_dataset" + ), patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "get_active_dataset_tlvs_failed" + + +async def test_create_network_fails_5( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test create network.""" + await async_setup_component(hass, "otbr", {}) + + with patch("python_otbr_api.OTBR.set_enabled"), patch( + "python_otbr_api.OTBR.create_active_dataset" + ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=None): + await websocket_client.send_json( + { + "id": 5, + "type": "otbr/create_network", + } + ) + msg = await websocket_client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "get_active_dataset_tlvs_empty" + + async def test_get_extended_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, From 8a4233ac8e5b667aac432e12755d19107509f5ba Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 13 Mar 2023 14:51:01 -0400 Subject: [PATCH 0452/1058] Bump SQLAlchemy to 2.0.6 (#89650) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index ed885127b1be..4f87c19ca7a5 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.5.post1", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.6", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index bdedbb9b2077..7513bbd8c7f9 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.5.post1"] + "requirements": ["sqlalchemy==2.0.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b79a0a5ef731..10df98fdb892 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 typing-extensions>=4.5.0,<5.0 ulid-transform==0.4.0 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 34148078091a..9e741884d6fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2398,7 +2398,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ad038042d74..4b7639560d3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1707,7 +1707,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.5.post1 +sqlalchemy==2.0.6 # homeassistant.components.srp_energy srpenergy==1.3.6 From 5104e7f51a1c1f10f95d6258137b3fc69fe02c0f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 13 Mar 2023 22:22:28 +0100 Subject: [PATCH 0453/1058] Use C-Extension for sqlalchemy (#89661) --- .github/workflows/wheels.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 144d6cbae163..e16e7f69fa6b 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -54,6 +54,9 @@ jobs: # OpenCV headless installation echo "CI_BUILD=1" echo "ENABLE_HEADLESS=1" + + # Use C-Extension for sqlalchemy + echo "REQUIRE_SQLALCHEMY_CEXT=1" ) > .env_file - name: Upload env_file From 0442a189e9ba95dac4894f6fda26b63be635df6f Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 14 Mar 2023 00:42:12 +0100 Subject: [PATCH 0454/1058] Add silent option for DynamicShutter (ogp:Shutter) in Overkiz (#89164) Add new switch --- homeassistant/components/overkiz/switch.py | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index b7416711e77e..a40bd731a0f2 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, cast from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState from pyoverkiz.enums.ui import UIClass, UIWidget @@ -15,12 +15,12 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantOverkizData -from .const import DOMAIN +from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity @@ -107,6 +107,19 @@ SWITCH_DESCRIPTIONS: list[OverkizSwitchDescription] = [ ), entity_category=EntityCategory.CONFIG, ), + OverkizSwitchDescription( + key=UIWidget.DYNAMIC_SHUTTER, + name="Silent mode", + turn_on=OverkizCommand.ACTIVATE_OPTION, + turn_on_args=OverkizCommandParam.SILENCE, + turn_off=OverkizCommand.DEACTIVATE_OPTION, + turn_off_args=OverkizCommandParam.SILENCE, + is_on=lambda select_state: ( + OverkizCommandParam.SILENCE + in cast(list, select_state(OverkizState.CORE_ACTIVATED_OPTIONS)) + ), + icon="mdi:feather", + ), ] SUPPORTED_DEVICES = { @@ -123,7 +136,13 @@ async def async_setup_entry( data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] entities: list[OverkizSwitch] = [] - for device in data.platforms[Platform.SWITCH]: + for device in data.coordinator.data.values(): + if ( + device.widget in IGNORED_OVERKIZ_DEVICES + or device.ui_class in IGNORED_OVERKIZ_DEVICES + ): + continue + if description := SUPPORTED_DEVICES.get(device.widget) or SUPPORTED_DEVICES.get( device.ui_class ): From cbffaf30ba5af91eaeb1df4a1a98c82112fb2d51 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 13 Mar 2023 20:52:01 -0400 Subject: [PATCH 0455/1058] Bump ZHA dependencies (#89667) * Bump `zha-quirks` library and account for `setup_quirks` signature * Bump other ZHA dependencies * Revert zigpy bump --- homeassistant/components/zha/__init__.py | 2 +- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index d0496fe7b60f..dd07d4da4280 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -107,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_data.setdefault(platform, []) if config.get(CONF_ENABLE_QUIRKS, True): - setup_quirks(config) + setup_quirks(custom_quirks_path=config.get(CONF_CUSTOM_QUIRKS_PATH)) # temporary code to remove the ZHA storage file from disk. # this will be removed in 2022.10.0 diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 44f88aa7339b..3061d867b657 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,10 +20,10 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.9", + "bellows==0.34.10", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.93", + "zha-quirks==0.0.94", "zigpy-deconz==0.19.2", "zigpy==0.53.2", "zigpy-xbee==0.16.2", diff --git a/requirements_all.txt b/requirements_all.txt index 9e741884d6fb..9aa47dde7f0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.9 +bellows==0.34.10 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -2706,7 +2706,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.93 +zha-quirks==0.0.94 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b7639560d3a..2844a2abe751 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.9 +bellows==0.34.10 # homeassistant.components.bmw_connected_drive bimmer_connected==0.12.1 @@ -1931,7 +1931,7 @@ zeroconf==0.47.3 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.93 +zha-quirks==0.0.94 # homeassistant.components.zha zigpy-deconz==0.19.2 From 671325355340cf25bd1b15431e165e72eb59e90e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 14:52:27 -1000 Subject: [PATCH 0456/1058] Bump ulid-transform to 0.4.2 (#89666) 32 bit fixes changelog: https://github.com/bdraco/ulid-transform/compare/v0.4.0...v0.4.2 --- 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 10df98fdb892..4bcddab1f8d4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ requests==2.28.2 scapy==2.5.0 sqlalchemy==2.0.6 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.0 +ulid-transform==0.4.2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 diff --git a/pyproject.toml b/pyproject.toml index bc7a603e5e2d..082ae3ef2ba8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.4.0", + "ulid-transform==0.4.2", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.8.1", diff --git a/requirements.txt b/requirements.txt index 478b8a64d500..168488a54d6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.2 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.0 +ulid-transform==0.4.2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.8.1 From 8e242c1fe6e8a1f3e0ea02bda98faa1e3fc8c352 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 14:52:53 -1000 Subject: [PATCH 0457/1058] Force binary build of sqlalchemy wheels (#89658) Force binary build of sqlalchemy --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e16e7f69fa6b..ae8ee1938e5b 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -179,7 +179,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libexecinfo-dev;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;grpcio + skip-binary: aiohttp;grpcio;sqlalchemy legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -194,7 +194,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libexecinfo-dev;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;grpcio + skip-binary: aiohttp;grpcio;sqlalchemy legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 6809bd3029d14b1915595e1d11dd097e6531d08f Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 14 Mar 2023 00:54:49 +0000 Subject: [PATCH 0458/1058] Remove incorrect state class for System Bridge sensors (#89655) --- homeassistant/components/system_bridge/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index eb835b2c9537..e73dec69c020 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -159,7 +159,6 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="kernel", name="Kernel", - state_class=SensorStateClass.MEASUREMENT, icon="mdi:devices", value=lambda data: data.system.platform, ), @@ -193,7 +192,6 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="os", name="Operating System", - state_class=SensorStateClass.MEASUREMENT, icon="mdi:devices", value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), @@ -232,7 +230,6 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( key="battery_time_remaining", name="Battery Time Remaining", device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, value=battery_time_remaining, ), ) From a99f6f71249180ea5a6766b0d1c38666906546cb Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 14 Mar 2023 00:55:37 +0000 Subject: [PATCH 0459/1058] Handle ConnectionClosedException from System Bridge (#89654) Handle unretrieved ConnectionClosedException from System Bridge --- .../components/system_bridge/coordinator.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 320c09a6f07d..2810bcfac72a 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -186,6 +186,12 @@ class SystemBridgeDataUpdateCoordinator( await self.websocket_client.connect( session=async_get_clientsession(self.hass), ) + + self.hass.async_create_task(self._listen_for_data()) + + await self.websocket_client.register_data_listener( + RegisterDataListener(modules=MODULES) + ) except AuthenticationException as exception: self.last_update_success = False self.logger.error("Authentication failed for %s: %s", self.title, exception) @@ -211,12 +217,6 @@ class SystemBridgeDataUpdateCoordinator( self.last_update_success = False self.async_update_listeners() - self.hass.async_create_task(self._listen_for_data()) - - await self.websocket_client.register_data_listener( - RegisterDataListener(modules=MODULES) - ) - self.last_update_success = True self.async_update_listeners() From 2f4e9c8ef38a7d9f2e9e0fc638decef402313c83 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Mar 2023 01:56:09 +0100 Subject: [PATCH 0460/1058] Use otbr domain as dataset source (#89653) --- homeassistant/components/otbr/__init__.py | 5 ++--- homeassistant/components/otbr/websocket_api.py | 2 +- tests/components/otbr/test_init.py | 4 ++-- tests/components/otbr/test_websocket_api.py | 3 ++- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index b1a9999f467b..d313c61c0ec5 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -60,7 +60,6 @@ class OTBRData: url: str api: python_otbr_api.OTBR - dataset_source: str @_handle_otbr_error async def set_enabled(self, enabled: bool) -> None: @@ -138,7 +137,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Open Thread Border Router config entry.""" api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10) - otbrdata = OTBRData(entry.data["url"], api, entry.title) + otbrdata = OTBRData(entry.data["url"], api) try: dataset_tlvs = await otbrdata.get_active_dataset_tlvs() except ( @@ -149,7 +148,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady("Unable to connect") from err if dataset_tlvs: _warn_on_default_network_settings(hass, entry, dataset_tlvs) - await async_add_dataset(hass, otbrdata.dataset_source, dataset_tlvs.hex()) + await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) hass.data[DOMAIN] = otbrdata diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 8ea993362398..3d885cd50071 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -106,7 +106,7 @@ async def websocket_create_network( connection.send_error(msg["id"], "get_active_dataset_tlvs_empty", "") return - await async_add_dataset(hass, data.dataset_source, dataset_tlvs.hex()) + await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) connection.send_result(msg["id"]) diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 86443ce5c0c2..2b329ae8d99b 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -42,7 +42,7 @@ async def test_import_dataset(hass: HomeAssistant) -> None: ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(config_entry.title, DATASET_CH16.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) assert not issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" ) @@ -72,7 +72,7 @@ async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> N ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_add.assert_called_once_with(config_entry.title, dataset.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex()) assert issue_registry.async_get_issue( domain=otbr.DOMAIN, issue_id=f"insecure_thread_network_{config_entry.entry_id}" ) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 087b5bb4865c..056563e7b879 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest import python_otbr_api +from homeassistant.components import otbr from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -130,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("Open Thread Border Router", DATASET_CH16.hex()) + mock_add.assert_called_once_with(otbr.DOMAIN, DATASET_CH16.hex()) async def test_create_network_no_entry( From afa58b80bd5d2f6d8277e19d00c2636471c15174 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 14 Mar 2023 04:41:32 +0100 Subject: [PATCH 0461/1058] Default to recorder db for SQL integration (#85436) Co-authored-by: J. Nick Koston --- homeassistant/components/sql/__init__.py | 17 ++++++++- homeassistant/components/sql/config_flow.py | 34 ++++++++++-------- homeassistant/components/sql/sensor.py | 15 ++++---- homeassistant/components/sql/util.py | 12 +++++++ tests/components/sql/__init__.py | 5 --- tests/components/sql/test_config_flow.py | 10 +----- tests/components/sql/test_init.py | 39 +++++++++++++++++++++ tests/components/sql/test_sensor.py | 17 ++------- tests/components/sql/test_util.py | 25 +++++++++++++ 9 files changed, 120 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/sql/util.py create mode 100644 tests/components/sql/test_util.py diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index c0ec2dfab7ff..92b640580eb9 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, @@ -53,6 +53,18 @@ CONFIG_SCHEMA = vol.Schema( ) +def remove_configured_db_url_if_not_needed( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Remove db url from config if it matches recorder database.""" + hass.config_entries.async_update_entry( + entry, + options={ + key: value for key, value in entry.options.items() if key != CONF_DB_URL + }, + ) + + async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener for options.""" await hass.config_entries.async_reload(entry.entry_id) @@ -73,6 +85,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SQL from a config entry.""" + if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: + remove_configured_db_url_if_not_needed(hass, entry) + 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/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index a6b1afe40494..d52f2d10d0d1 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -11,13 +11,14 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.recorder import CONF_DB_URL, DEFAULT_DB_FILE, DEFAULT_URL +from homeassistant.components.recorder import CONF_DB_URL from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN +from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) @@ -85,34 +86,37 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the user step.""" errors = {} - db_url_default = DEFAULT_URL.format( - hass_config_path=self.hass.config.path(DEFAULT_DB_FILE) - ) if user_input is not None: - db_url = user_input.get(CONF_DB_URL, db_url_default) + db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] uom = user_input.get(CONF_UNIT_OF_MEASUREMENT) value_template = user_input.get(CONF_VALUE_TEMPLATE) name = user_input[CONF_NAME] + db_url_for_validation = None try: validate_sql_select(query) + db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( - validate_query, db_url, query, column + validate_query, db_url_for_validation, query, column ) except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: errors["query"] = "query_invalid" + add_db_url = ( + {CONF_DB_URL: db_url} if db_url == db_url_for_validation else {} + ) + if not errors: return self.async_create_entry( title=name, data={}, options={ - CONF_DB_URL: db_url, + **add_db_url, CONF_QUERY: query, CONF_COLUMN_NAME: column, CONF_UNIT_OF_MEASUREMENT: uom, @@ -140,32 +144,32 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): ) -> FlowResult: """Manage SQL options.""" errors = {} - db_url_default = DEFAULT_URL.format( - hass_config_path=self.hass.config.path(DEFAULT_DB_FILE) - ) if user_input is not None: - db_url = user_input.get(CONF_DB_URL, db_url_default) + db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] name = self.entry.options.get(CONF_NAME, self.entry.title) try: validate_sql_select(query) + db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( - validate_query, db_url, query, column + validate_query, db_url_for_validation, query, column ) except SQLAlchemyError: errors["db_url"] = "db_url_invalid" except ValueError: errors["query"] = "query_invalid" else: + new_user_input = user_input + if new_user_input.get(CONF_DB_URL) and db_url == db_url_for_validation: + new_user_input.pop(CONF_DB_URL) return self.async_create_entry( title="", data={ CONF_NAME: name, - CONF_DB_URL: db_url, - **user_input, + **new_user_input, }, ) @@ -176,7 +180,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_DB_URL, description={ - "suggested_value": self.entry.options[CONF_DB_URL] + "suggested_value": self.entry.options.get(CONF_DB_URL) }, ): selector.TextSelector(), vol.Required( diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 27cf798db385..26c899d4d3c7 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -10,7 +10,7 @@ from sqlalchemy.engine import Result from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker -from homeassistant.components.recorder import CONF_DB_URL, DEFAULT_DB_FILE, DEFAULT_URL +from homeassistant.components.recorder import CONF_DB_URL from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, @@ -34,6 +34,7 @@ from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN +from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) @@ -59,7 +60,7 @@ async def async_setup_platform( value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE) column_name: str = conf[CONF_COLUMN_NAME] unique_id: str | None = conf.get(CONF_UNIQUE_ID) - db_url: str | None = conf.get(CONF_DB_URL) + db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL)) device_class: SensorDeviceClass | None = conf.get(CONF_DEVICE_CLASS) state_class: SensorStateClass | None = conf.get(CONF_STATE_CLASS) @@ -87,7 +88,7 @@ async def async_setup_entry( ) -> None: """Set up the SQL sensor from config entry.""" - db_url: str = entry.options[CONF_DB_URL] + db_url: str = resolve_db_url(hass, entry.options.get(CONF_DB_URL)) name: str = entry.options[CONF_NAME] query_str: str = entry.options[CONF_QUERY] unit: str | None = entry.options.get(CONF_UNIT_OF_MEASUREMENT) @@ -128,7 +129,7 @@ async def async_setup_sensor( unit: str | None, value_template: Template | None, unique_id: str | None, - db_url: str | None, + db_url: str, yaml: bool, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, @@ -136,16 +137,12 @@ async def async_setup_sensor( ) -> None: """Set up the SQL sensor.""" - if not db_url: - db_url = DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE)) - - sess: Session | None = None try: engine = sqlalchemy.create_engine(db_url, future=True) sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) # Run a dummy query just to test the db_url - sess = sessmaker() + sess: Session = sessmaker() sess.execute(sqlalchemy.text("SELECT 1;")) except SQLAlchemyError as err: diff --git a/homeassistant/components/sql/util.py b/homeassistant/components/sql/util.py new file mode 100644 index 000000000000..81d8cd9900cb --- /dev/null +++ b/homeassistant/components/sql/util.py @@ -0,0 +1,12 @@ +"""Utils for sql.""" +from __future__ import annotations + +from homeassistant.components.recorder import get_instance +from homeassistant.core import HomeAssistant + + +def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str: + """Return the db_url provided if not empty, otherwise return the recorder db_url.""" + if db_url and not db_url.isspace(): + return db_url + return get_instance(hass).db_url diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index f6cfba01e359..ea58d066325e 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_DB_URL: "sqlite://", CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", @@ -31,7 +30,6 @@ ENTRY_CONFIG = { } ENTRY_CONFIG_INVALID_QUERY = { - CONF_DB_URL: "sqlite://", CONF_NAME: "Get Value", CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", @@ -39,14 +37,12 @@ ENTRY_CONFIG_INVALID_QUERY = { } ENTRY_CONFIG_INVALID_QUERY_OPT = { - CONF_DB_URL: "sqlite://", CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", CONF_UNIT_OF_MEASUREMENT: "MiB", } ENTRY_CONFIG_NO_RESULTS = { - CONF_DB_URL: "sqlite://", CONF_NAME: "Get Value", CONF_QUERY: "SELECT kalle as value from no_table;", CONF_COLUMN_NAME: "value", @@ -69,7 +65,6 @@ YAML_CONFIG = { YAML_CONFIG_INVALID = { "sql": { - CONF_DB_URL: "sqlite://", CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", CONF_UNIT_OF_MEASUREMENT: "MiB", diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 789fc9838908..3213296a479b 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -6,7 +6,7 @@ from unittest.mock import patch from sqlalchemy.exc import SQLAlchemyError from homeassistant import config_entries -from homeassistant.components.recorder import DEFAULT_DB_FILE, DEFAULT_URL, Recorder +from homeassistant.components.recorder import Recorder from homeassistant.components.sql.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -43,7 +43,6 @@ async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "Get Value" assert result2["options"] == { - "db_url": "sqlite://", "name": "Get Value", "query": "SELECT 5 as value", "column": "value", @@ -113,7 +112,6 @@ async def test_flow_fails_invalid_query( assert result5["type"] == FlowResultType.CREATE_ENTRY assert result5["title"] == "Get Value" assert result5["options"] == { - "db_url": "sqlite://", "name": "Get Value", "query": "SELECT 5 as value", "column": "value", @@ -163,7 +161,6 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value", - "db_url": "sqlite://", "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", @@ -215,7 +212,6 @@ async def test_options_flow_name_previously_removed( assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value Title", - "db_url": "sqlite://", "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", @@ -316,7 +312,6 @@ async def test_options_flow_fails_invalid_query( assert result4["type"] == FlowResultType.CREATE_ENTRY assert result4["data"] == { "name": "Get Value", - "db_url": "sqlite://", "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", @@ -369,12 +364,9 @@ async def test_options_flow_db_url_empty( ) await hass.async_block_till_done() - db_url = DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE)) - assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { "name": "Get Value", - "db_url": db_url, "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", diff --git a/tests/components/sql/test_init.py b/tests/components/sql/test_init.py index a110f789a937..50de8aba7b31 100644 --- a/tests/components/sql/test_init.py +++ b/tests/components/sql/test_init.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.util import get_instance from homeassistant.components.sql import validate_sql_select from homeassistant.components.sql.const import DOMAIN from homeassistant.core import HomeAssistant @@ -56,3 +57,41 @@ async def test_invalid_query(hass: HomeAssistant) -> None: """Test invalid query.""" with pytest.raises(vol.Invalid): validate_sql_select("DROP TABLE *") + + +async def test_remove_configured_db_url_if_not_needed_when_not_needed( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test configured db_url is replaced with None if matching the recorder db.""" + recorder_db_url = get_instance(hass).db_url + + config = { + "db_url": recorder_db_url, + "query": "SELECT 5 as value", + "column": "value", + "name": "count_tables", + } + + config_entry = await init_integration(hass, config) + + assert config_entry.options.get("db_url") is None + + +async def test_remove_configured_db_url_if_not_needed_when_needed( + recorder_mock: Recorder, + hass: HomeAssistant, +) -> None: + """Test configured db_url is not replaced if it differs from the recorder db.""" + db_url = "mssql://" + + config = { + "db_url": db_url, + "query": "SELECT 5 as value", + "column": "value", + "name": "count_tables", + } + + config_entry = await init_integration(hass, config) + + assert config_entry.options.get("db_url") == db_url diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index bc3143347b50..32e5a778a87c 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -182,6 +182,7 @@ async def test_invalid_url_setup( async def test_invalid_url_on_update( + recorder_mock: Recorder, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: @@ -192,22 +193,9 @@ async def test_invalid_url_on_update( "column": "value", "name": "count_tables", } - entry = MockConfigEntry( - domain=DOMAIN, - source=SOURCE_USER, - data={}, - options=config, - entry_id="1", - ) - - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await init_integration(hass, config) with patch( - "homeassistant.components.recorder", - ), patch( "homeassistant.components.sql.sensor.sqlalchemy.engine.cursor.CursorResult", side_effect=SQLAlchemyError( "sqlite://homeassistant:hunter2@homeassistant.local" @@ -219,7 +207,6 @@ async def test_invalid_url_on_update( ) await hass.async_block_till_done() - assert "sqlite://homeassistant:hunter2@homeassistant.local" not in caplog.text assert "sqlite://****:****@homeassistant.local" in caplog.text diff --git a/tests/components/sql/test_util.py b/tests/components/sql/test_util.py new file mode 100644 index 000000000000..31adbe076ebb --- /dev/null +++ b/tests/components/sql/test_util.py @@ -0,0 +1,25 @@ +"""Test the sql utils.""" +from unittest.mock import AsyncMock + +from homeassistant.components.recorder import get_instance +from homeassistant.components.sql.util import resolve_db_url +from homeassistant.core import HomeAssistant + + +async def test_resolve_db_url_when_none_configured( + recorder_mock: AsyncMock, + hass: HomeAssistant, +): + """Test return recorder db_url if provided db_url is None.""" + db_url = None + resolved_url = resolve_db_url(hass, db_url) + + assert resolved_url == get_instance(hass).db_url + + +async def test_resolve_db_url_when_configured(hass: HomeAssistant): + """Test return provided db_url if it's set.""" + db_url = "mssql://" + resolved_url = resolve_db_url(hass, db_url) + + assert resolved_url == db_url From 2cb673db04d4ad4fbd7ff3513cba98aa20126ba8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 18:07:05 -1000 Subject: [PATCH 0462/1058] Handle bytes data in sql sensors (#89169) --- homeassistant/components/sql/sensor.py | 7 ++++++- tests/components/sql/__init__.py | 10 ++++++++++ tests/components/sql/test_sensor.py | 14 +++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 26c899d4d3c7..39a11049aaa6 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -242,10 +242,15 @@ class SQLSensor(SensorEntity): for key, value in res.items(): if isinstance(value, decimal.Decimal): value = float(value) - if isinstance(value, date): + elif isinstance(value, date): value = value.isoformat() + elif isinstance(value, (bytes, bytearray)): + value = f"0x{value.hex()}" self._attr_extra_state_attributes[key] = value + if data is not None and isinstance(data, (bytes, bytearray)): + data = f"0x{data.hex()}" + if data is not None and self._template is not None: self._attr_native_value = ( self._template.async_render_with_possible_json_value(data, None) diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index ea58d066325e..c794f7a6b9ac 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -63,6 +63,16 @@ YAML_CONFIG = { } } +YAML_CONFIG_BINARY = { + "sql": { + CONF_DB_URL: "sqlite://", + CONF_NAME: "Get Binary Value", + CONF_QUERY: "SELECT cast(x'd34324324230392032' as blob) as value, cast(x'd343aa' as blob) as test_attr", + CONF_COLUMN_NAME: "value", + CONF_UNIQUE_ID: "unique_id_12345", + } +} + YAML_CONFIG_INVALID = { "sql": { CONF_QUERY: "SELECT 5 as value", diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 32e5a778a87c..400e3056d5af 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt -from . import YAML_CONFIG, init_integration +from . import YAML_CONFIG, YAML_CONFIG_BINARY, init_integration from tests.common import MockConfigEntry, async_fire_time_changed @@ -304,3 +304,15 @@ async def test_attributes_from_yaml_setup( assert state.attributes["device_class"] == SensorDeviceClass.DATA_RATE assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT assert state.attributes["unit_of_measurement"] == "MiB" + + +async def test_binary_data_from_yaml_setup( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test binary data from yaml config.""" + + assert await async_setup_component(hass, DOMAIN, YAML_CONFIG_BINARY) + await hass.async_block_till_done() + state = hass.states.get("sensor.get_binary_value") + assert state.state == "0xd34324324230392032" + assert state.attributes["test_attr"] == "0xd343aa" From cbee1ba496477675c21a75bb90fe6cce9414bb7c Mon Sep 17 00:00:00 2001 From: amitfin Date: Tue, 14 Mar 2023 10:47:26 +0200 Subject: [PATCH 0463/1058] Increase timeout for coolmaster with swing (#87573) Co-authored-by: G Johansson --- .../components/coolmaster/__init__.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 129797c356f9..289e70e80670 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -21,9 +21,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Coolmaster from a config entry.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] - coolmaster = CoolMasterNet( - host, port, swing_support=entry.data.get(CONF_SWING_SUPPORT, False) - ) + if not entry.data.get(CONF_SWING_SUPPORT): + coolmaster = CoolMasterNet( + host, + port, + ) + else: + # Swing support adds an additional request per unit. The requests are + # done in parallel, which can cause delays on the server. Therefore, + # we increase the request timeout to 5 seconds instead of 1. + coolmaster = CoolMasterNet( + host, + port, + read_timeout=5, + swing_support=True, + ) try: info = await coolmaster.info() if not info: From b620e5d8a6b527bbd37d71338d29ac05940e1e39 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Mar 2023 09:51:03 +0100 Subject: [PATCH 0464/1058] Move nextcloud constants (#89679) --- .../components/nextcloud/__init__.py | 61 ++----------------- .../components/nextcloud/binary_sensor.py | 9 ++- homeassistant/components/nextcloud/const.py | 5 ++ homeassistant/components/nextcloud/sensor.py | 48 ++++++++++++++- 4 files changed, 65 insertions(+), 58 deletions(-) create mode 100644 homeassistant/components/nextcloud/const.py diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 269bd96aa31b..b4080dd2a1ad 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,5 +1,4 @@ """The Nextcloud integration.""" -from datetime import timedelta import logging from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError @@ -17,11 +16,11 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + _LOGGER = logging.getLogger(__name__) -DOMAIN = "nextcloud" PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) -SCAN_INTERVAL = timedelta(seconds=60) # Validate user configuration CONFIG_SCHEMA = vol.Schema( @@ -31,64 +30,14 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_URL): cv.url, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, } ) }, extra=vol.ALLOW_EXTRA, ) -BINARY_SENSORS = ( - "nextcloud_system_enable_avatars", - "nextcloud_system_enable_previews", - "nextcloud_system_filelocking.enabled", - "nextcloud_system_debug", -) - -SENSORS = ( - "nextcloud_system_version", - "nextcloud_system_theme", - "nextcloud_system_memcache.local", - "nextcloud_system_memcache.distributed", - "nextcloud_system_memcache.locking", - "nextcloud_system_freespace", - "nextcloud_system_cpuload", - "nextcloud_system_mem_total", - "nextcloud_system_mem_free", - "nextcloud_system_swap_total", - "nextcloud_system_swap_free", - "nextcloud_system_apps_num_installed", - "nextcloud_system_apps_num_updates_available", - "nextcloud_system_apps_app_updates_calendar", - "nextcloud_system_apps_app_updates_contacts", - "nextcloud_system_apps_app_updates_tasks", - "nextcloud_system_apps_app_updates_twofactor_totp", - "nextcloud_storage_num_users", - "nextcloud_storage_num_files", - "nextcloud_storage_num_storages", - "nextcloud_storage_num_storages_local", - "nextcloud_storage_num_storages_home", - "nextcloud_storage_num_storages_other", - "nextcloud_shares_num_shares", - "nextcloud_shares_num_shares_user", - "nextcloud_shares_num_shares_groups", - "nextcloud_shares_num_shares_link", - "nextcloud_shares_num_shares_mail", - "nextcloud_shares_num_shares_room", - "nextcloud_shares_num_shares_link_no_password", - "nextcloud_shares_num_fed_shares_sent", - "nextcloud_shares_num_fed_shares_received", - "nextcloud_shares_permissions_3_1", - "nextcloud_server_webserver", - "nextcloud_server_php_version", - "nextcloud_server_php_memory_limit", - "nextcloud_server_php_max_execution_time", - "nextcloud_server_php_upload_max_filesize", - "nextcloud_database_type", - "nextcloud_database_version", - "nextcloud_activeUsers_last5minutes", - "nextcloud_activeUsers_last1hour", - "nextcloud_activeUsers_last24hours", -) def setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index e9d5b4a8d7fc..d811c6c9249e 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -6,7 +6,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import BINARY_SENSORS, DOMAIN +from . import DOMAIN + +BINARY_SENSORS = ( + "nextcloud_system_enable_avatars", + "nextcloud_system_enable_previews", + "nextcloud_system_filelocking.enabled", + "nextcloud_system_debug", +) def setup_platform( diff --git a/homeassistant/components/nextcloud/const.py b/homeassistant/components/nextcloud/const.py new file mode 100644 index 000000000000..223d21771beb --- /dev/null +++ b/homeassistant/components/nextcloud/const.py @@ -0,0 +1,5 @@ +"""Constants for Nextcloud integration.""" +from datetime import timedelta + +DOMAIN = "nextcloud" +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 31caa46028f9..6f1ce00eeeb1 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -6,7 +6,53 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN, SENSORS +from . import DOMAIN + +SENSORS = ( + "nextcloud_system_version", + "nextcloud_system_theme", + "nextcloud_system_memcache.local", + "nextcloud_system_memcache.distributed", + "nextcloud_system_memcache.locking", + "nextcloud_system_freespace", + "nextcloud_system_cpuload", + "nextcloud_system_mem_total", + "nextcloud_system_mem_free", + "nextcloud_system_swap_total", + "nextcloud_system_swap_free", + "nextcloud_system_apps_num_installed", + "nextcloud_system_apps_num_updates_available", + "nextcloud_system_apps_app_updates_calendar", + "nextcloud_system_apps_app_updates_contacts", + "nextcloud_system_apps_app_updates_tasks", + "nextcloud_system_apps_app_updates_twofactor_totp", + "nextcloud_storage_num_users", + "nextcloud_storage_num_files", + "nextcloud_storage_num_storages", + "nextcloud_storage_num_storages_local", + "nextcloud_storage_num_storages_home", + "nextcloud_storage_num_storages_other", + "nextcloud_shares_num_shares", + "nextcloud_shares_num_shares_user", + "nextcloud_shares_num_shares_groups", + "nextcloud_shares_num_shares_link", + "nextcloud_shares_num_shares_mail", + "nextcloud_shares_num_shares_room", + "nextcloud_shares_num_shares_link_no_password", + "nextcloud_shares_num_fed_shares_sent", + "nextcloud_shares_num_fed_shares_received", + "nextcloud_shares_permissions_3_1", + "nextcloud_server_webserver", + "nextcloud_server_php_version", + "nextcloud_server_php_memory_limit", + "nextcloud_server_php_max_execution_time", + "nextcloud_server_php_upload_max_filesize", + "nextcloud_database_type", + "nextcloud_database_version", + "nextcloud_activeUsers_last5minutes", + "nextcloud_activeUsers_last1hour", + "nextcloud_activeUsers_last24hours", +) def setup_platform( From dbc0890ce8844143d1984bc46b9a14840515ea7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 23:09:21 -1000 Subject: [PATCH 0465/1058] Add index to event_type and entity_id (#89676) --- homeassistant/components/recorder/db_schema.py | 10 +++++++--- homeassistant/components/recorder/migration.py | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index b715ef9bc589..a310161457bb 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 40 +SCHEMA_VERSION = 41 _LOGGER = logging.getLogger(__name__) @@ -348,7 +348,9 @@ class EventTypes(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_EVENT_TYPES event_type_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - event_type: Mapped[str | None] = mapped_column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_type: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_EVENT_EVENT_TYPE), index=True + ) def __repr__(self) -> str: """Return string representation of instance for debugging.""" @@ -597,7 +599,9 @@ class StatesMeta(Base): __table_args__ = (_DEFAULT_TABLE_ARGS,) __tablename__ = TABLE_STATES_META metadata_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) - entity_id: Mapped[str | None] = mapped_column(String(MAX_LENGTH_STATE_ENTITY_ID)) + entity_id: Mapped[str | None] = mapped_column( + String(MAX_LENGTH_STATE_ENTITY_ID), index=True + ) def __repr__(self) -> str: """Return string representation of instance for debugging.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 5b2180773a34..74fbcf307fb1 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1054,6 +1054,9 @@ def _apply_update( # noqa: C901 "statistics_short_term", "ix_statistics_short_term_metadata_id", ) + elif new_version == 41: + _create_index(session_maker, "event_types", "ix_event_types_event_type") + _create_index(session_maker, "states_meta", "ix_states_meta_entity_id") else: raise ValueError(f"No schema migration defined for version {new_version}") From 03b204f445aa6d9fc2a2a732778833c8a6808788 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Mar 2023 23:56:02 -1000 Subject: [PATCH 0466/1058] Execute sql queries in the database executor when using the recorder database (#89673) --- homeassistant/components/sql/sensor.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 39a11049aaa6..95227bac65b0 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -10,7 +10,7 @@ from sqlalchemy.engine import Result from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker -from homeassistant.components.recorder import CONF_DB_URL +from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, @@ -136,7 +136,6 @@ async def async_setup_sensor( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" - try: engine = sqlalchemy.create_engine(db_url, future=True) sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) @@ -163,6 +162,8 @@ async def async_setup_sensor( else: query_str = query_str.replace(";", "") + " LIMIT 1;" + use_database_executor = db_url == get_instance(hass).db_url + async_add_entities( [ SQLSensor( @@ -176,6 +177,7 @@ async def async_setup_sensor( yaml, device_class, state_class, + use_database_executor, ) ], True, @@ -200,6 +202,7 @@ class SQLSensor(SensorEntity): yaml: bool, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, + use_database_executor: bool, ) -> None: """Initialize the SQL sensor.""" self._query = query @@ -212,6 +215,7 @@ class SQLSensor(SensorEntity): self.sessionmaker = sessmaker self._attr_extra_state_attributes = {} self._attr_unique_id = unique_id + self._use_database_executor = use_database_executor if not yaml and unique_id: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -220,9 +224,15 @@ class SQLSensor(SensorEntity): name=name, ) - def update(self) -> None: - """Retrieve sensor data from the query.""" + async def async_update(self) -> None: + """Retrieve sensor data from the query using the right executor.""" + if self._use_database_executor: + await get_instance(self.hass).async_add_executor_job(self._update) + else: + await self.hass.async_add_executor_job(self._update) + def _update(self) -> None: + """Retrieve sensor data from the query.""" data = None self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() From ec1b8b616f73d5b7f7021cc0fc405be34a5ed115 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 14 Mar 2023 11:13:55 +0100 Subject: [PATCH 0467/1058] Debounce and group MQTT subscriptions (#88862) * Debounce and group mqtt subscriptions * Cleanup * Do not cooldown on resubscribe * Remove lock from task Co-authored-by: Erik Montnemery * ruff * Longer initial cool down. Manages unsubscribes * Own lock for access to self._pending_subscriptions * adjust * Subscribe to highest QoS when sharing subscription * do not block _pending_subscriptions_lock with io * Test the highest qos is subscribed at * Cleanup max qos * Follow up comments part 1 * Make docstr more generic * Make max qos update thread safe * Add lock on clearing _max_qos when resubscribing * Wait for linger task * User copy * Check for key before cleaning up * Fix lingering task * Do not use a lock * do not await _async_queue_subscriptions * Replace copy with assignment * Update max qos before returning * Do not iterate if max_qos == 0 * Do not ieterate subs if max qos == 0 * Set initial cooldown correctly * Ensure discovery cooldown ends after subscribing * plan last subscribe with debouncer timeout * cooldown if self._pending_subscriptions is set * Revert format changes * Remove stale assingnment self._last_subscribe * Remove not used property * Also check while for pending subscriptions * revert first added sleep() * Optimize --------- Co-authored-by: Erik Montnemery Co-authored-by: J. Nick Koston --- homeassistant/components/mqtt/client.py | 151 +++++++++++++-- tests/components/mqtt/test_discovery.py | 6 + tests/components/mqtt/test_init.py | 242 ++++++++++++++++++++++-- 3 files changed, 361 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index e717da5144c3..5585a6cee5f1 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -83,6 +83,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) DISCOVERY_COOLDOWN = 2 +INITIAL_SUBSCRIBE_COOLDOWN = 1.0 +SUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 SubscribePayloadType = str | bytes # Only bytes if encoding is None @@ -295,10 +297,86 @@ def _is_simple_match(topic: str) -> bool: return not ("+" in topic or "#" in topic) +class EnsureJobAfterCooldown: + """Ensure a cool down period before executing a job. + + When a new execute request arrives we cancel the current request + and start a new one. + """ + + def __init__( + self, timeout: float, callback_job: Callable[[], Coroutine[Any, None, None]] + ) -> None: + """Initialize the timer.""" + self._loop = asyncio.get_running_loop() + self._timeout = timeout + self._callback = callback_job + self._task: asyncio.Future | None = None + self._timer: asyncio.TimerHandle | None = None + + def set_timeout(self, timeout: float) -> None: + """Set a new timeout period.""" + self._timeout = timeout + + async def _async_job(self) -> None: + """Execute after a cooldown period.""" + try: + await self._callback() + except HomeAssistantError as ha_error: + _LOGGER.error("%s", ha_error) + + @callback + def _async_task_done(self, task: asyncio.Future) -> None: + """Handle task done.""" + self._task = None + + @callback + def _async_execute(self) -> None: + """Execute the job.""" + if self._task: + # Task already running, + # so we schedule another run + self.async_schedule() + return + + self._async_cancel_timer() + self._task = asyncio.create_task(self._async_job()) + self._task.add_done_callback(self._async_task_done) + + @callback + def _async_cancel_timer(self) -> None: + """Cancel any pending task.""" + if self._timer: + self._timer.cancel() + self._timer = None + + @callback + def async_schedule(self) -> None: + """Ensure we execute after a cooldown period.""" + # We want to reschedule the timer in the future + # every time this is called. + self._async_cancel_timer() + self._timer = self._loop.call_later(self._timeout, self._async_execute) + + async def async_cleanup(self) -> None: + """Cleanup any pending task.""" + self._async_cancel_timer() + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error cleaning up task", exc_info=True) + + class MQTT: """Home Assistant MQTT client.""" _mqttc: mqtt.Client + _last_subscribe: float def __init__( self, @@ -316,12 +394,16 @@ class MQTT: self._wildcard_subscriptions: list[Subscription] = [] self.connected = False self._ha_started = asyncio.Event() - self._last_subscribe = time.time() self._cleanup_on_unload: list[Callable[[], None]] = [] self._paho_lock = asyncio.Lock() # Prevents parallel calls to the MQTT client self._pending_operations: dict[int, asyncio.Event] = {} self._pending_operations_condition = asyncio.Condition() + self._subscribe_debouncer = EnsureJobAfterCooldown( + INITIAL_SUBSCRIBE_COOLDOWN, self._async_perform_subscriptions + ) + self._max_qos: dict[str, int] = {} # topic, max qos + self._pending_subscriptions: dict[str, int] = {} # topic, qos if self.hass.state == CoreState.running: self._ha_started.set() @@ -442,6 +524,11 @@ class MQTT: """Return False if there are unprocessed ACKs.""" return not any(not op.is_set() for op in self._pending_operations.values()) + # stop waiting for any pending subscriptions + await self._subscribe_debouncer.async_cleanup() + # reset timeout to initial subscribe cooldown + self._subscribe_debouncer.set_timeout(INITIAL_SUBSCRIBE_COOLDOWN) + # wait for ACKs to be processed async with self._pending_operations_condition: await self._pending_operations_condition.wait_for(no_more_acks) @@ -494,6 +581,20 @@ class MQTT: except (KeyError, ValueError) as ex: raise HomeAssistantError("Can't remove subscription twice") from ex + @callback + def _async_queue_subscriptions( + self, subscriptions: Iterable[tuple[str, int]], queue_only: bool = False + ) -> None: + """Queue requested subscriptions.""" + for subscription in subscriptions: + topic, qos = subscription + max_qos = max(qos, self._max_qos.setdefault(topic, qos)) + self._max_qos[topic] = max_qos + self._pending_subscriptions[topic] = max_qos + if queue_only: + return + self._subscribe_debouncer.async_schedule() + async def async_subscribe( self, topic: str, @@ -516,15 +617,13 @@ class MQTT: # Only subscribe if currently connected. if self.connected: - self._last_subscribe = time.time() - await self._async_perform_subscriptions(((topic, qos),)) + self._async_queue_subscriptions(((topic, qos),)) @callback def async_remove() -> None: """Remove subscription.""" self._async_untrack_subscription(subscription) self._matching_subscriptions.cache_clear() - # Only unsubscribe if currently connected if self.connected: self.hass.async_create_task(self._async_unsubscribe(topic)) @@ -543,21 +642,27 @@ class MQTT: _raise_on_error(result) return mid - async with self._paho_lock: - if self._is_active_subscription(topic): - # Other subscriptions on topic remaining - don't unsubscribe. + if self._is_active_subscription(topic): + if self._max_qos[topic] == 0: return - + subs = self._matching_subscriptions(topic) + self._max_qos[topic] = max(sub.qos for sub in subs) + # Other subscriptions on topic remaining - don't unsubscribe. + return + if topic in self._max_qos: + del self._max_qos[topic] + if topic in self._pending_subscriptions: + # avoid any pending subscription to be executed + del self._pending_subscriptions[topic] + async with self._paho_lock: mid = await self.hass.async_add_executor_job(_client_unsubscribe, topic) await self._register_mid(mid) self.hass.async_create_task(self._wait_for_mid(mid)) - async def _async_perform_subscriptions( - self, subscriptions: Iterable[tuple[str, int]] - ) -> None: + async def _async_perform_subscriptions(self) -> None: """Perform MQTT client subscriptions.""" - + subscriptions: dict[str, int] # Section 3.3.1.3 in the specification: # http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html # When sending a PUBLISH Packet to a Client the Server MUST @@ -573,16 +678,20 @@ class MQTT: def _process_client_subscriptions() -> list[tuple[int, int]]: """Initiate all subscriptions on the MQTT client and return the results.""" subscribe_result_list = [] - for topic, qos in subscriptions: + for topic, qos in subscriptions.items(): result, mid = self._mqttc.subscribe(topic, qos) subscribe_result_list.append((result, mid)) _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) return subscribe_result_list + subscriptions = self._pending_subscriptions + self._pending_subscriptions = {} + async with self._paho_lock: results = await self.hass.async_add_executor_job( _process_client_subscriptions ) + self._last_subscribe = time.time() tasks: list[Coroutine[Any, Any, None]] = [] errors: list[int] = [] @@ -639,6 +748,8 @@ class MQTT: async def publish_birth_message(birth_message: PublishMessage) -> None: await self._ha_started.wait() # Wait for Home Assistant to start await self._discovery_cooldown() # Wait for MQTT discovery to cool down + # Update subscribe cooldown period to a shorter time + self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) await self.async_publish( topic=birth_message.topic, payload=birth_message.payload, @@ -654,16 +765,19 @@ class MQTT: async def _async_resubscribe(self) -> None: """Resubscribe on reconnect.""" # Group subscriptions to only re-subscribe once for each topic. + self._max_qos.clear() keyfunc = attrgetter("topic") - await self._async_perform_subscriptions( + self._async_queue_subscriptions( [ # Re-subscribe with the highest requested qos (topic, max(subscription.qos for subscription in subs)) for topic, subs in groupby( sorted(self.subscriptions, key=keyfunc), keyfunc ) - ] + ], + queue_only=True, ) + await self._async_perform_subscriptions() def _mqtt_on_message( self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage @@ -785,13 +899,14 @@ class MQTT: self._pending_operations_condition.notify_all() async def _discovery_cooldown(self) -> None: + """Wait until all discovery and subscriptions are processed.""" now = time.time() # Reset discovery and subscribe cooldowns self._mqtt_data.last_discovery = now self._last_subscribe = now last_discovery = self._mqtt_data.last_discovery - last_subscribe = self._last_subscribe + last_subscribe = now if self._pending_subscriptions else self._last_subscribe wait_until = max( last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN ) @@ -799,7 +914,9 @@ class MQTT: await asyncio.sleep(wait_until - now) now = time.time() last_discovery = self._mqtt_data.last_discovery - last_subscribe = self._last_subscribe + last_subscribe = ( + now if self._pending_subscriptions else self._last_subscribe + ) wait_until = max( last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN ) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 5cd615e0eb63..a21f69544e80 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1374,6 +1374,8 @@ async def test_complex_discovery_topic_prefix( @patch("homeassistant.components.mqtt.PLATFORMS", []) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_mqtt_integration_discovery_subscribe_unsubscribe( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1392,6 +1394,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( ): await async_start(hass, "homeassistant", entry) await hass.async_block_till_done() + await hass.async_block_till_done() mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) assert not mqtt_client_mock.unsubscribe.called @@ -1418,6 +1421,8 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( @patch("homeassistant.components.mqtt.PLATFORMS", []) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_mqtt_discovery_unsubscribe_once( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1436,6 +1441,7 @@ async def test_mqtt_discovery_unsubscribe_once( ): await async_start(hass, "homeassistant", entry) await hass.async_block_till_done() + await hass.async_block_till_done() mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) assert not mqtt_client_mock.unsubscribe.called diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 8c6ee21c932b..ec373aab0d7e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -17,6 +17,7 @@ import yaml from homeassistant import config as hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import CONFIG_SCHEMA, debug_info +from homeassistant.components.mqtt.client import EnsureJobAfterCooldown from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.components.mqtt.models import MessageCallbackType, ReceiveMessage from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState @@ -1262,6 +1263,9 @@ async def test_subscribe_special_characters( assert calls[0].payload == payload +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_subscribe_same_topic( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1286,26 +1290,35 @@ async def test_subscribe_same_topic( def _callback_b(msg: ReceiveMessage) -> None: calls_b.append(msg) - await mqtt.async_subscribe(hass, "test/state", _callback_a) + await mqtt.async_subscribe(hass, "test/state", _callback_a, qos=0) async_fire_mqtt_message( hass, "test/state", "online" - ) # Simulate a (retained) message + ) # Simulate a (retained) message replaying + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert len(calls_a) == 1 mqtt_client_mock.subscribe.assert_called() calls_a = [] mqtt_client_mock.reset_mock() - await mqtt.async_subscribe(hass, "test/state", _callback_b) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + await mqtt.async_subscribe(hass, "test/state", _callback_b, qos=1) async_fire_mqtt_message( hass, "test/state", "online" - ) # Simulate a (retained) message + ) # Simulate a (retained) message replaying + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert len(calls_a) == 1 assert len(calls_b) == 1 mqtt_client_mock.subscribe.assert_called() +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_not_calling_unsubscribe_with_active_subscribers( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1317,8 +1330,10 @@ async def test_not_calling_unsubscribe_with_active_subscribers( # Fake that the client is connected mqtt_mock().connected = True - unsub = await mqtt.async_subscribe(hass, "test/state", record_calls) - await mqtt.async_subscribe(hass, "test/state", record_calls) + unsub = await mqtt.async_subscribe(hass, "test/state", record_calls, 2) + await mqtt.async_subscribe(hass, "test/state", record_calls, 1) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() assert mqtt_client_mock.subscribe.called @@ -1327,6 +1342,30 @@ async def test_not_calling_unsubscribe_with_active_subscribers( assert not mqtt_client_mock.unsubscribe.called +async def test_not_calling_subscribe_when_unsubscribed_within_cooldown( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + record_calls: MessageCallbackType, +) -> None: + """Test not calling subscribe() when it is unsubscribed. + + Make sure subscriptions are cleared if unsubscribed before + the subscribe cool down period has ended. + """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + # Fake that the client is connected + mqtt_mock().connected = True + + unsub = await mqtt.async_subscribe(hass, "test/state", record_calls) + unsub() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + assert not mqtt_client_mock.subscribe.called + + +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) async def test_unsubscribe_race( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1351,13 +1390,15 @@ async def test_unsubscribe_race( unsub() await mqtt.async_subscribe(hass, "test/state", _callback_b) await hass.async_block_till_done() + await hass.async_block_till_done() async_fire_mqtt_message(hass, "test/state", "online") await hass.async_block_till_done() assert not calls_a assert calls_b - # We allow either calls [subscribe, unsubscribe, subscribe] or [subscribe, subscribe] + # We allow either calls [subscribe, unsubscribe, subscribe], [subscribe, subscribe] or + # when both subscriptions were combined [subscribe] expected_calls_1 = [ call.subscribe("test/state", 0), call.unsubscribe("test/state"), @@ -1367,13 +1408,23 @@ async def test_unsubscribe_race( call.subscribe("test/state", 0), call.subscribe("test/state", 0), ] - assert mqtt_client_mock.mock_calls in (expected_calls_1, expected_calls_2) + expected_calls_3 = [ + call.subscribe("test/state", 0), + ] + assert mqtt_client_mock.mock_calls in ( + expected_calls_1, + expected_calls_2, + expected_calls_3, + ) @pytest.mark.parametrize( "mqtt_config_entry_data", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], ) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) async def test_restore_subscriptions_on_reconnect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1386,13 +1437,15 @@ async def test_restore_subscriptions_on_reconnect( mqtt_mock().connected = True await mqtt.async_subscribe(hass, "test/state", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() assert mqtt_client_mock.subscribe.call_count == 1 mqtt_client_mock.on_disconnect(None, None, 0) - with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0): - mqtt_client_mock.on_connect(None, None, None, 0) - await hass.async_block_till_done() + mqtt_client_mock.on_connect(None, None, None, 0) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + await hass.async_block_till_done() assert mqtt_client_mock.subscribe.call_count == 2 @@ -1400,6 +1453,9 @@ async def test_restore_subscriptions_on_reconnect( "mqtt_config_entry_data", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], ) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 1.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 1.0) async def test_restore_all_active_subscriptions_on_reconnect( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1412,14 +1468,15 @@ async def test_restore_all_active_subscriptions_on_reconnect( mqtt_mock().connected = True unsub = await mqtt.async_subscribe(hass, "test/state", record_calls, qos=2) - await mqtt.async_subscribe(hass, "test/state", record_calls) await mqtt.async_subscribe(hass, "test/state", record_calls, qos=1) + await mqtt.async_subscribe(hass, "test/state", record_calls, qos=0) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() + # the subscribtion with the highest QoS should survive expected = [ call("test/state", 2), - call("test/state", 0), - call("test/state", 1), ] assert mqtt_client_mock.subscribe.mock_calls == expected @@ -1428,13 +1485,60 @@ async def test_restore_all_active_subscriptions_on_reconnect( assert mqtt_client_mock.unsubscribe.call_count == 0 mqtt_client_mock.on_disconnect(None, None, 0) - with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0): - mqtt_client_mock.on_connect(None, None, None, 0) - await hass.async_block_till_done() + await hass.async_block_till_done() + mqtt_client_mock.on_connect(None, None, None, 0) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() expected.append(call("test/state", 1)) assert mqtt_client_mock.subscribe.mock_calls == expected + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], +) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 1.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 1.0) +async def test_subscribed_at_highest_qos( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + record_calls: MessageCallbackType, +) -> None: + """Test the highest qos as assigned when subscribing to the same topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + # Fake that the client is connected + mqtt_mock().connected = True + + await mqtt.async_subscribe(hass, "test/state", record_calls, qos=0) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown + await hass.async_block_till_done() + assert mqtt_client_mock.subscribe.mock_calls == [ + call("test/state", 0), + ] + mqtt_client_mock.reset_mock() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown + await hass.async_block_till_done() + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state", record_calls, qos=1) + await mqtt.async_subscribe(hass, "test/state", record_calls, qos=2) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown + await hass.async_block_till_done() + # the subscribtion with the highest QoS should survive + assert mqtt_client_mock.subscribe.mock_calls == [ + call("test/state", 2), + ] + async def test_reload_entry_with_restored_subscriptions( hass: HomeAssistant, @@ -1499,6 +1603,93 @@ async def test_reload_entry_with_restored_subscriptions( assert calls[1].payload == "wild-card-payload3" +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 2) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 2) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 2) +async def test_canceling_debouncer_on_shutdown( + hass: HomeAssistant, + record_calls: MessageCallbackType, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test canceling the debouncer when HA shuts down.""" + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + + # Fake that the client is connected + mqtt_mock().connected = True + + await mqtt.async_subscribe(hass, "test/state1", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=0.2)) + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state2", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=0.2)) + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state3", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=0.2)) + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state4", record_calls) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=0.2)) + await hass.async_block_till_done() + + await mqtt.async_subscribe(hass, "test/state5", record_calls) + + mqtt_client_mock.subscribe.assert_not_called() + + # Stop HA so the scheduled task will be canceled + hass.bus.fire(EVENT_HOMEASSISTANT_STOP) + # mock disconnect status + mqtt_client_mock.on_disconnect(None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_not_called() + + +async def test_canceling_debouncer_normal( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test canceling the debouncer before completion.""" + + async def _async_myjob() -> None: + await asyncio.sleep(1.0) + + debouncer = EnsureJobAfterCooldown(0.0, _async_myjob) + debouncer.async_schedule() + await asyncio.sleep(0.01) + assert debouncer._task is not None + await debouncer.async_cleanup() + assert debouncer._task is None + + +async def test_canceling_debouncer_throws( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test canceling the debouncer when HA shuts down.""" + + async def _async_myjob() -> None: + await asyncio.sleep(1.0) + + debouncer = EnsureJobAfterCooldown(0.0, _async_myjob) + debouncer.async_schedule() + await asyncio.sleep(0.01) + assert debouncer._task is not None + # let debouncer._task fail by mocking it + with patch.object(debouncer, "_task") as task: + task.cancel = MagicMock(return_value=True) + await debouncer.async_cleanup() + assert "Error cleaning up task" in caplog.text + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + + async def test_initial_setup_logs_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -1575,21 +1766,30 @@ async def test_publish_error( assert "Failed to connect to MQTT server: Out of memory." in caplog.text +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) async def test_subscribe_error( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, record_calls: MessageCallbackType, + caplog: pytest.LogCaptureFixture, ) -> None: """Test publish error.""" await mqtt_mock_entry_no_yaml_config() mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) await hass.async_block_till_done() - with pytest.raises(HomeAssistantError): - # simulate client is not connected error before subscribing - mqtt_client_mock.subscribe.side_effect = lambda *args: (4, None) - await mqtt.async_subscribe(hass, "some-topic", record_calls) + await hass.async_block_till_done() + mqtt_client_mock.reset_mock() + # simulate client is not connected error before subscribing + mqtt_client_mock.subscribe.side_effect = lambda *args: (4, None) + await mqtt.async_subscribe(hass, "some-topic", record_calls) + while mqtt_client_mock.subscribe.call_count == 0: await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + assert ( + "Error talking to MQTT: The client is not currently connected." in caplog.text + ) async def test_handle_message_callback( From 2809a686be7fd5ed074ccf65c7cb1bf7f8a1aad2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Mar 2023 12:14:29 +0100 Subject: [PATCH 0468/1058] Remove duplicate code in nextcloud (#89681) --- .../components/nextcloud/binary_sensor.py | 33 +++---------------- homeassistant/components/nextcloud/entity.py | 26 +++++++++++++++ homeassistant/components/nextcloud/sensor.py | 33 +++---------------- 3 files changed, 36 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/nextcloud/entity.py diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index d811c6c9249e..6e0df919f90d 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -6,7 +6,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN +from .const import DOMAIN +from .entity import NextcloudEntity BINARY_SENSORS = ( "nextcloud_system_enable_avatars", @@ -32,34 +33,10 @@ def setup_platform( add_entities(binary_sensors, True) -class NextcloudBinarySensor(BinarySensorEntity): +class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): """Represents a Nextcloud binary sensor.""" - def __init__(self, item): - """Initialize the Nextcloud binary sensor.""" - self._name = item - self._is_on = None - @property - def icon(self): - """Return the icon for this binary sensor.""" - return "mdi:cloud" - - @property - def name(self): - """Return the name for this binary sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._is_on == "yes" - - @property - def unique_id(self): - """Return the unique ID for this binary sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self._name}" - - def update(self) -> None: - """Update the binary sensor.""" - self._is_on = self.hass.data[DOMAIN][self._name] + return self._state == "yes" diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py new file mode 100644 index 000000000000..cb066e0fcf76 --- /dev/null +++ b/homeassistant/components/nextcloud/entity.py @@ -0,0 +1,26 @@ +"""Base entity for the Nextcloud integration.""" +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN + + +class NextcloudEntity(Entity): + """Base Nextcloud entity.""" + + _attr_icon = "mdi:cloud" + + def __init__(self, item: str) -> None: + """Initialize the Nextcloud entity.""" + self._attr_name = item + self.item = item + self._state: StateType = None + + @property + def unique_id(self): + """Return the unique ID for this sensor.""" + return f"{self.hass.data[DOMAIN]['instance']}#{self.item}" + + def update(self) -> None: + """Update the sensor.""" + self._state = self.hass.data[DOMAIN][self.item] diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 6f1ce00eeeb1..91d4411b0cbc 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -4,9 +4,10 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from . import DOMAIN +from .const import DOMAIN +from .entity import NextcloudEntity SENSORS = ( "nextcloud_system_version", @@ -71,34 +72,10 @@ def setup_platform( add_entities(sensors, True) -class NextcloudSensor(SensorEntity): +class NextcloudSensor(NextcloudEntity, SensorEntity): """Represents a Nextcloud sensor.""" - def __init__(self, item): - """Initialize the Nextcloud sensor.""" - self._name = item - self._state = None - @property - def icon(self): - """Return the icon for this sensor.""" - return "mdi:cloud" - - @property - def name(self): - """Return the name for this sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state for this sensor.""" return self._state - - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self._name}" - - def update(self) -> None: - """Update the sensor.""" - self._state = self.hass.data[DOMAIN][self._name] From 73e1942eebe79ccf2bae3a4b342e15b532bfcc78 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 14 Mar 2023 11:28:43 +0000 Subject: [PATCH 0469/1058] Update entity names to capitalize first word only for System Bridge (#89688) --- .../components/system_bridge/binary_sensor.py | 4 +- .../components/system_bridge/sensor.py | 48 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 8feb1114285f..bb83d90235fc 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -29,7 +29,7 @@ class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( SystemBridgeBinarySensorEntityDescription( key="version_available", - name="New Version Available", + name="New version available", device_class=BinarySensorDeviceClass.UPDATE, value=lambda data: data.system.version_newer_available, ), @@ -38,7 +38,7 @@ BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] = ( SystemBridgeBinarySensorEntityDescription( key="battery_is_charging", - name="Battery Is Charging", + name="Battery is charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, value=lambda data: data.battery.is_charging, ), diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index e73dec69c020..a6bf29ac5460 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -122,7 +122,7 @@ def memory_used(data: SystemBridgeCoordinatorData) -> float | None: BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( SystemBridgeSensorEntityDescription( key="boot_time", - name="Boot Time", + name="Boot time", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:av-timer", value=lambda data: datetime.fromtimestamp( @@ -131,7 +131,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_speed", - name="CPU Speed", + name="CPU speed", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -140,7 +140,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_temperature", - name="CPU Temperature", + name="CPU temperature", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -149,7 +149,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="cpu_voltage", - name="CPU Voltage", + name="CPU voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -164,7 +164,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_free", - name="Memory Free", + name="Memory free", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -173,7 +173,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_used_percentage", - name="Memory Used %", + name="Memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -181,7 +181,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="memory_used", - name="Memory Used", + name="Memory used", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, @@ -191,7 +191,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="os", - name="Operating System", + name="Operating system", icon="mdi:devices", value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), @@ -211,7 +211,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="version_latest", - name="Latest Version", + name="Latest version", icon="mdi:counter", value=lambda data: data.system.version_latest, ), @@ -228,7 +228,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( ), SystemBridgeSensorEntityDescription( key="battery_time_remaining", - name="Battery Time Remaining", + name="Battery time remaining", device_class=SensorDeviceClass.TIMESTAMP, value=battery_time_remaining, ), @@ -255,7 +255,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"filesystem_{partition.replace(':', '')}", - name=f"{partition} Space Used", + name=f"{partition} space used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", @@ -296,7 +296,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key="displays_connected", - name="Displays Connected", + name="Displays connected", state_class=SensorStateClass.MEASUREMENT, icon="mdi:monitor", value=lambda _, count=display_count: count, @@ -312,7 +312,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display['name']}_resolution_x", - name=f"Display {display['name']} Resolution X", + name=f"Display {display['name']} resolution x", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", @@ -326,7 +326,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display['name']}_resolution_y", - name=f"Display {display['name']} Resolution Y", + name=f"Display {display['name']} resolution y", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", @@ -340,7 +340,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"display_{display['name']}_refresh_rate", - name=f"Display {display['name']} Refresh Rate", + name=f"Display {display['name']} refresh rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, @@ -371,7 +371,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_core_clock_speed", - name=f"{gpu['name']} Clock Speed", + name=f"{gpu['name']} clock speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, @@ -385,7 +385,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_clock_speed", - name=f"{gpu['name']} Memory Clock Speed", + name=f"{gpu['name']} memory clock speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ, @@ -399,7 +399,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_free", - name=f"{gpu['name']} Memory Free", + name=f"{gpu['name']} memory free", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -412,7 +412,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_used_percentage", - name=f"{gpu['name']} Memory Used %", + name=f"{gpu['name']} memory used %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -426,7 +426,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_memory_used", - name=f"{gpu['name']} Memory Used", + name=f"{gpu['name']} memory used", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfInformation.GIGABYTES, @@ -440,7 +440,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_fan_speed", - name=f"{gpu['name']} Fan Speed", + name=f"{gpu['name']} fan speed", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, @@ -455,7 +455,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_power_usage", - name=f"{gpu['name']} Power Usage", + name=f"{gpu['name']} power usage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -470,7 +470,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_temperature", - name=f"{gpu['name']} Temperature", + name=f"{gpu['name']} temperature", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -485,7 +485,7 @@ async def async_setup_entry( coordinator, SystemBridgeSensorEntityDescription( key=f"gpu_{index}_usage_percentage", - name=f"{gpu['name']} Usage %", + name=f"{gpu['name']} usage %", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", From a213ef24757e95413e935da169e926dec29c20eb Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 14 Mar 2023 15:27:31 +0100 Subject: [PATCH 0470/1058] Add websocket command to set preferred thread dataset (#89700) --- .../components/thread/websocket_api.py | 26 ++++++++++ tests/components/thread/test_websocket_api.py | 50 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 053ec69a0faf..9f9bc3455a8e 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -20,6 +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_dataset) @websocket_api.require_admin @@ -49,6 +50,31 @@ async def ws_add_dataset( connection.send_result(msg["id"]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "thread/set_preferred_dataset", + vol.Required("dataset_id"): str, + } +) +@websocket_api.async_response +async def ws_set_preferred_dataset( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Add a thread dataset.""" + dataset_id = msg["dataset_id"] + + store = await dataset_store.async_get_store(hass) + if not (store.async_get(dataset_id)): + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "unknown dataset" + ) + return + + store.preferred_dataset = dataset_id + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index f8f09b0b8cca..c2e9e5f59340 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -197,6 +197,56 @@ async def test_list_get_dataset( assert msg["error"] == {"code": "not_found", "message": "unknown dataset"} +async def test_set_preferred_dataset( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we set a dataset as default.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + datasets = [ + {"source": "Google", "tlv": DATASET_1}, + {"source": "Multipan", "tlv": DATASET_2}, + {"source": "🎅", "tlv": DATASET_3}, + ] + for dataset in datasets: + await dataset_store.async_add_dataset(hass, dataset["source"], dataset["tlv"]) + + store = await dataset_store.async_get_store(hass) + + for dataset in store.datasets.values(): + if dataset.source == "🎅": + dataset_3 = dataset + + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 1, "type": "thread/set_preferred_dataset", "dataset_id": dataset_3.id} + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + store = await dataset_store.async_get_store(hass) + assert store.preferred_dataset == dataset_3.id + + +async def test_set_preferred_dataset_wrong_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we set a dataset as default.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 1, "type": "thread/set_preferred_dataset", "dataset_id": "don_t_exist"} + ) + msg = await client.receive_json() + assert msg["error"]["code"] == "not_found" + + async def test_discover_routers( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_async_zeroconf: None ) -> None: From 85e01771955ceb105ae62a20c6ab1f492ae1738f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Mar 2023 15:28:06 +0100 Subject: [PATCH 0471/1058] Add WS command for connecting OTBR to a known Thread network (#89692) * Add WS command for connecting OTBR to a known Thread network * Add test --- homeassistant/components/otbr/__init__.py | 5 + .../components/otbr/websocket_api.py | 66 +++++- homeassistant/components/thread/__init__.py | 8 +- .../components/thread/dataset_store.py | 8 + tests/components/otbr/test_websocket_api.py | 188 +++++++++++++++++- tests/components/thread/test_dataset_store.py | 11 + 6 files changed, 282 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index d313c61c0ec5..602c76f77ef9 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -78,6 +78,11 @@ class OTBRData: """Create an active operational dataset.""" return await self.api.create_active_dataset(dataset) + @_handle_otbr_error + async def set_active_dataset_tlvs(self, dataset: bytes) -> None: + """Set current active operational dataset in TLVS format.""" + await self.api.set_active_dataset_tlvs(dataset) + @_handle_otbr_error async def get_extended_address(self) -> bytes: """Get extended address (EUI-64).""" diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 3d885cd50071..aa8c1dd2dd99 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -2,9 +2,11 @@ from typing import TYPE_CHECKING import python_otbr_api +from python_otbr_api import tlv_parser +import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.thread import async_add_dataset +from homeassistant.components.thread import async_add_dataset, async_get_dataset from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -20,6 +22,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_info) websocket_api.async_register_command(hass, websocket_create_network) websocket_api.async_register_command(hass, websocket_get_extended_address) + websocket_api.async_register_command(hass, websocket_set_network) @websocket_api.websocket_command( @@ -111,6 +114,67 @@ async def websocket_create_network( connection.send_result(msg["id"]) +@websocket_api.websocket_command( + { + "type": "otbr/set_network", + vol.Required("dataset_id"): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_set_network( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Set the Thread network to be used by the OTBR.""" + if DOMAIN not in hass.data: + connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") + return + + dataset_tlv = await async_get_dataset(hass, msg["dataset_id"]) + + if not dataset_tlv: + connection.send_error(msg["id"], "unknown_dataset", "Unknown dataset") + return + dataset = tlv_parser.parse_tlv(dataset_tlv) + if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): + thread_dataset_channel = int(channel_str, base=16) + + # We currently have no way to know which channel zha is using, assume it's + # the default + zha_channel = DEFAULT_CHANNEL + + if thread_dataset_channel != zha_channel: + connection.send_error( + msg["id"], + "channel_conflict", + f"Can't connect to network on channel {thread_dataset_channel}, ZHA is " + f"using channel {zha_channel}", + ) + return + + data: OTBRData = hass.data[DOMAIN] + + try: + await data.set_enabled(False) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_enabled_failed", str(exc)) + return + + try: + await data.set_active_dataset_tlvs(bytes.fromhex(dataset_tlv)) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_active_dataset_tlvs_failed", str(exc)) + return + + try: + await data.set_enabled(True) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_enabled_failed", str(exc)) + return + + connection.send_result(msg["id"]) + + @websocket_api.websocket_command( { "type": "otbr/get_extended_address", diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index 345fca854d2c..4fc88479818d 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -6,13 +6,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .dataset_store import DatasetEntry, async_add_dataset, async_get_preferred_dataset +from .dataset_store import ( + DatasetEntry, + async_add_dataset, + async_get_dataset, + async_get_preferred_dataset, +) from .websocket_api import async_setup as async_setup_ws_api __all__ = [ "DOMAIN", "DatasetEntry", "async_add_dataset", + "async_get_dataset", "async_get_preferred_dataset", ] diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index b9a27b617e68..ea5a16f90cd6 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -159,6 +159,14 @@ async def async_add_dataset(hass: HomeAssistant, source: str, tlv: str) -> None: store.async_add(source, tlv) +async def async_get_dataset(hass: HomeAssistant, dataset_id: str) -> str | None: + """Get a dataset.""" + store = await async_get_store(hass) + if (entry := store.async_get(dataset_id)) is None: + return None + return entry.tlv + + async def async_get_preferred_dataset(hass: HomeAssistant) -> str | None: """Get the preferred dataset.""" store = await async_get_store(hass) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 056563e7b879..04210a3433ef 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -4,11 +4,11 @@ from unittest.mock import patch import pytest import python_otbr_api -from homeassistant.components import otbr +from homeassistant.components import otbr, thread from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import BASE_URL, DATASET_CH16 +from . import BASE_URL, DATASET_CH15, DATASET_CH16 from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -290,6 +290,190 @@ async def test_create_network_fails_5( assert msg["error"]["code"] == "get_active_dataset_tlvs_empty" +async def test_set_network( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + + await thread.async_add_dataset(hass, "test", DATASET_CH15.hex()) + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[1] + + with patch( + "python_otbr_api.OTBR.set_active_dataset_tlvs" + ) as set_active_dataset_tlvs_mock, patch( + "python_otbr_api.OTBR.set_enabled" + ) as set_enabled_mock: + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + + msg = await websocket_client.receive_json() + assert msg["success"] + assert msg["result"] is None + + set_active_dataset_tlvs_mock.assert_called_once_with(DATASET_CH15) + assert len(set_enabled_mock.mock_calls) == 2 + assert set_enabled_mock.mock_calls[0][1][0] is False + assert set_enabled_mock.mock_calls[1][1][0] is True + + +async def test_set_network_no_entry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test set network.""" + await async_setup_component(hass, "otbr", {}) + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": "abc", + } + ) + + msg = await websocket_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + +async def test_set_network_channel_conflict( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[0] + + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "channel_conflict" + + +async def test_set_network_unknown_dataset( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": "abc", + } + ) + + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "unknown_dataset" + + +async def test_set_network_fails_1( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + await thread.async_add_dataset(hass, "test", DATASET_CH15.hex()) + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[1] + + with patch( + "python_otbr_api.OTBR.set_enabled", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "set_enabled_failed" + + +async def test_set_network_fails_2( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + await thread.async_add_dataset(hass, "test", DATASET_CH15.hex()) + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[1] + + with patch( + "python_otbr_api.OTBR.set_enabled", + ), patch( + "python_otbr_api.OTBR.set_active_dataset_tlvs", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "set_active_dataset_tlvs_failed" + + +async def test_set_network_fails_3( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry, + websocket_client, +) -> None: + """Test set network.""" + await thread.async_add_dataset(hass, "test", DATASET_CH15.hex()) + dataset_store = await thread.dataset_store.async_get_store(hass) + dataset_id = list(dataset_store.datasets)[1] + + with patch( + "python_otbr_api.OTBR.set_enabled", + side_effect=[None, python_otbr_api.OTBRError], + ), patch( + "python_otbr_api.OTBR.set_active_dataset_tlvs", + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "set_enabled_failed" + + async def test_get_extended_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 553068ab8bdb..581329e860a3 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -83,6 +83,17 @@ async def test_delete_preferred_dataset(hass: HomeAssistant) -> None: assert len(store.datasets) == 1 +async def test_get_dataset(hass: HomeAssistant) -> None: + """Test get the preferred dataset.""" + assert await dataset_store.async_get_dataset(hass, "blah") is 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 + + assert (await dataset_store.async_get_dataset(hass, dataset_id)) == DATASET_1 + + async def test_get_preferred_dataset(hass: HomeAssistant) -> None: """Test get the preferred dataset.""" assert await dataset_store.async_get_preferred_dataset(hass) is None From 1bc4802c04860d68637c622b2843f582653655bf Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 14 Mar 2023 08:12:44 -0700 Subject: [PATCH 0472/1058] Move local calendar text fixtures to conftest.py (#89674) * Move local calendar text fixtures to conftest.py * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add imports for suggested typing fixes * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- tests/components/local_calendar/conftest.py | 161 ++++++++++++++++++ .../local_calendar/test_calendar.py | 161 +----------------- 2 files changed, 169 insertions(+), 153 deletions(-) create mode 100644 tests/components/local_calendar/conftest.py diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py new file mode 100644 index 000000000000..02c984c284e9 --- /dev/null +++ b/tests/components/local_calendar/conftest.py @@ -0,0 +1,161 @@ +"""Fixtures for local calendar.""" + +from collections.abc import Awaitable, Callable, Generator +from http import HTTPStatus +from pathlib import Path +from typing import Any +from unittest.mock import patch +import urllib + +from aiohttp import ClientWebSocketResponse +import pytest + +from homeassistant.components.local_calendar import LocalCalendarStore +from homeassistant.components.local_calendar.const import CONF_CALENDAR_NAME, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator, WebSocketGenerator + +CALENDAR_NAME = "Light Schedule" +FRIENDLY_NAME = "Light schedule" +TEST_ENTITY = "calendar.light_schedule" + + +class FakeStore(LocalCalendarStore): + """Mock storage implementation.""" + + def __init__(self, hass: HomeAssistant, path: Path) -> None: + """Initialize FakeStore.""" + super().__init__(hass, path) + self._content = "" + + def _load(self) -> str: + """Read from calendar storage.""" + return self._content + + def _store(self, ics_content: str) -> None: + """Persist the calendar storage.""" + self._content = ics_content + + +@pytest.fixture(name="store", autouse=True) +def mock_store() -> Generator[None, None, None]: + """Test cleanup, remove any media storage persisted during the test.""" + + stores: dict[Path, FakeStore] = {} + + def new_store(hass: HomeAssistant, path: Path) -> FakeStore: + if path not in stores: + stores[path] = FakeStore(hass, path) + return stores[path] + + with patch( + "homeassistant.components.local_calendar.LocalCalendarStore", new=new_store + ): + yield + + +@pytest.fixture(name="time_zone") +def mock_time_zone() -> str: + """Fixture for time zone to use in tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + return "America/Regina" + + +@pytest.fixture(autouse=True) +def set_time_zone(hass: HomeAssistant, time_zone: str): + """Set the time zone for the tests.""" + # Set our timezone to CST/Regina so we can check calculations + # This keeps UTC-6 all year round + hass.config.set_time_zone(time_zone) + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for mock configuration entry.""" + return MockConfigEntry(domain=DOMAIN, data={CONF_CALENDAR_NAME: CALENDAR_NAME}) + + +@pytest.fixture(name="setup_integration") +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] + + +@pytest.fixture(name="get_events") +def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> None: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + return await response.json() + + return _fetch + + +def event_fields(data: dict[str, str]) -> dict[str, str]: + """Filter event API response to minimum fields.""" + return { + k: data.get(k) + for k in ["summary", "start", "end", "recurrence_id"] + if data.get(k) + } + + +class Client: + """Test client with helper methods for calendar websocket.""" + + def __init__(self, client: ClientWebSocketResponse) -> None: + """Initialize Client.""" + self.client = client + self.id = 0 + + async def cmd(self, cmd: str, payload: dict[str, Any] = None) -> dict[str, Any]: + """Send a command and receive the json result.""" + self.id += 1 + await self.client.send_json( + { + "id": self.id, + "type": f"calendar/event/{cmd}", + **(payload if payload is not None else {}), + } + ) + resp = await self.client.receive_json() + assert resp.get("id") == self.id + return resp + + async def cmd_result(self, cmd: str, payload: dict[str, Any] = None) -> Any: + """Send a command and parse the result.""" + resp = await self.cmd(cmd, payload) + assert resp.get("success") + assert resp.get("type") == "result" + return resp.get("result") + + +ClientFixture = Callable[[], Awaitable[Client]] + + +@pytest.fixture +async def ws_client( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> ClientFixture: + """Fixture for creating the test websocket client.""" + + async def create_client() -> Client: + ws_client = await hass_ws_client(hass) + return Client(ws_client) + + return create_client diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index f432fe3f9771..8364f6df6298 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -1,168 +1,23 @@ """Tests for calendar platform of local calendar.""" -from collections.abc import Awaitable, Callable import datetime -from http import HTTPStatus -from pathlib import Path -from typing import Any -from unittest.mock import patch -import urllib -from aiohttp import ClientWebSocketResponse import pytest -from homeassistant.components.local_calendar import LocalCalendarStore -from homeassistant.components.local_calendar.const import CONF_CALENDAR_NAME, DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.template import DATE_STR_FORMAT -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .conftest import ( + FRIENDLY_NAME, + TEST_ENTITY, + ClientFixture, + GetEventsFn, + event_fields, +) + from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator - -CALENDAR_NAME = "Light Schedule" -FRIENDLY_NAME = "Light schedule" -TEST_ENTITY = "calendar.light_schedule" - - -class FakeStore(LocalCalendarStore): - """Mock storage implementation.""" - - def __init__(self, hass: HomeAssistant, path: Path) -> None: - """Initialize FakeStore.""" - super().__init__(hass, path) - self._content = "" - - def _load(self) -> str: - """Read from calendar storage.""" - return self._content - - def _store(self, ics_content: str) -> None: - """Persist the calendar storage.""" - self._content = ics_content - - -@pytest.fixture(name="store", autouse=True) -def mock_store() -> None: - """Test cleanup, remove any media storage persisted during the test.""" - - stores: dict[Path, FakeStore] = {} - - def new_store(hass: HomeAssistant, path: Path) -> FakeStore: - if path not in stores: - stores[path] = FakeStore(hass, path) - return stores[path] - - with patch( - "homeassistant.components.local_calendar.LocalCalendarStore", new=new_store - ): - yield - - -@pytest.fixture(name="time_zone") -def mock_time_zone() -> str: - """Fixture for time zone to use in tests.""" - # Set our timezone to CST/Regina so we can check calculations - # This keeps UTC-6 all year round - return "America/Regina" - - -@pytest.fixture(autouse=True) -def set_time_zone(hass: HomeAssistant, time_zone: str): - """Set the time zone for the tests.""" - # Set our timezone to CST/Regina so we can check calculations - # This keeps UTC-6 all year round - hass.config.set_time_zone(time_zone) - - -@pytest.fixture(name="config_entry") -def mock_config_entry() -> MockConfigEntry: - """Fixture for mock configuration entry.""" - return MockConfigEntry(domain=DOMAIN, data={CONF_CALENDAR_NAME: CALENDAR_NAME}) - - -@pytest.fixture(name="setup_integration") -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: - """Set up the integration.""" - config_entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - -GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] - - -@pytest.fixture(name="get_events") -def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: - """Fetch calendar events from the HTTP API.""" - - async def _fetch(start: str, end: str) -> None: - client = await hass_client() - response = await client.get( - f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" - ) - assert response.status == HTTPStatus.OK - return await response.json() - - return _fetch - - -def event_fields(data: dict[str, str]) -> dict[str, str]: - """Filter event API response to minimum fields.""" - return { - k: data.get(k) - for k in ["summary", "start", "end", "recurrence_id"] - if data.get(k) - } - - -class Client: - """Test client with helper methods for calendar websocket.""" - - def __init__(self, client): - """Initialize Client.""" - self.client = client - self.id = 0 - - async def cmd(self, cmd: str, payload: dict[str, Any] = None) -> dict[str, Any]: - """Send a command and receive the json result.""" - self.id += 1 - await self.client.send_json( - { - "id": self.id, - "type": f"calendar/event/{cmd}", - **(payload if payload is not None else {}), - } - ) - resp = await self.client.receive_json() - assert resp.get("id") == self.id - return resp - - async def cmd_result(self, cmd: str, payload: dict[str, Any] = None) -> Any: - """Send a command and parse the result.""" - resp = await self.cmd(cmd, payload) - assert resp.get("success") - assert resp.get("type") == "result" - return resp.get("result") - - -ClientFixture = Callable[[], Awaitable[Client]] - - -@pytest.fixture -async def ws_client( - hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], -) -> ClientFixture: - """Fixture for creating the test websocket client.""" - - async def create_client() -> Client: - ws_client = await hass_ws_client(hass) - return Client(ws_client) - - return create_client async def test_empty_calendar( From 71dc98a39c68dbd30b0cff6f6a26ffeef7858fc2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Mar 2023 16:31:40 +0100 Subject: [PATCH 0473/1058] Improve hass_ws_client type hint in tests (#89703) --- tests/components/automation/test_init.py | 4 +--- tests/components/backup/test_websocket.py | 10 +++++----- tests/components/devolo_home_control/test_init.py | 6 +++--- tests/components/dlink/test_switch.py | 4 ++-- tests/components/google/test_calendar.py | 5 ++--- tests/components/matter/test_api.py | 11 +++++------ tests/components/matter/test_init.py | 8 ++++---- tests/components/mysensors/test_init.py | 6 ++---- tests/components/onewire/test_init.py | 7 +++---- tests/components/pushover/test_init.py | 7 ++----- tests/components/repairs/__init__.py | 6 +++--- tests/components/repairs/test_init.py | 4 +--- tests/components/repairs/test_websocket_api.py | 4 +--- tests/components/rtsp_to_webrtc/test_init.py | 10 +++++----- tests/components/schedule/test_init.py | 12 ++++++------ tests/components/unifiprotect/test_init.py | 10 +++------- tests/components/update/test_init.py | 9 ++++----- 17 files changed, 52 insertions(+), 71 deletions(-) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index b47e6637655b..ff4ede357a4f 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,11 +1,9 @@ """The tests for the automation component.""" import asyncio -from collections.abc import Awaitable, Callable from datetime import timedelta import logging from unittest.mock import Mock, patch -from aiohttp import ClientWebSocketResponse import pytest import homeassistant.components.automation as automation @@ -1437,7 +1435,7 @@ async def test_automation_bad_config_validation( async def test_automation_with_error_in_script( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test automation with an error in script.""" assert await async_setup_component( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 2e8ac8f2f56a..5a50f1afa8a2 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -1,18 +1,18 @@ """Tests for the Backup integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import patch -from aiohttp import ClientWebSocketResponse import pytest from homeassistant.core import HomeAssistant from .common import TEST_BACKUP, setup_backup_integration +from tests.typing import WebSocketGenerator + async def test_info( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test getting backup info.""" await setup_backup_integration(hass) @@ -34,7 +34,7 @@ async def test_info( async def test_remove( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test removing a backup file.""" @@ -55,7 +55,7 @@ async def test_remove( async def test_generate( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test removing a backup file.""" await setup_backup_integration(hass) diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 9ded392a48f9..0eb011d5ef2f 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -1,8 +1,6 @@ """Tests for the devolo Home Control integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import patch -from aiohttp import ClientWebSocketResponse from devolo_home_control_api.exceptions.gateway import GatewayOfflineError import pytest @@ -16,6 +14,8 @@ from homeassistant.setup import async_setup_component from . import configure_integration from .mocks import HomeControlMock, HomeControlMockBinarySensor +from tests.typing import WebSocketGenerator + async def test_setup_entry(hass: HomeAssistant, mock_zeroconf: None) -> None: """Test setup entry.""" @@ -64,7 +64,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_remove_device( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ): """Test removing a device.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/dlink/test_switch.py b/tests/components/dlink/test_switch.py index 66c40892a66a..683d30be7f64 100644 --- a/tests/components/dlink/test_switch.py +++ b/tests/components/dlink/test_switch.py @@ -1,5 +1,4 @@ """Switch tests for the D-Link Smart Plug integration.""" -from collections.abc import Awaitable, Callable from homeassistant.components.dlink import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -16,11 +15,12 @@ from homeassistant.setup import async_setup_component from .conftest import ComponentSetup from tests.components.repairs import get_repairs +from tests.typing import WebSocketGenerator async def test_switch_state( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[None]], + hass_ws_client: WebSocketGenerator, setup_integration: ComponentSetup, ) -> None: """Test we get the switch status.""" diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 3997795c39aa..8b544a828e90 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -8,7 +8,6 @@ from typing import Any from unittest.mock import patch import urllib -from aiohttp import ClientWebSocketResponse from aiohttp.client_exceptions import ClientError from gcal_sync.auth import API_BASE_URL import pytest @@ -32,7 +31,7 @@ from .conftest import ( from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator TEST_ENTITY = TEST_API_ENTITY TEST_ENTITY_NAME = TEST_API_ENTITY_NAME @@ -134,7 +133,7 @@ ClientFixture = Callable[[], Awaitable[Client]] @pytest.fixture async def ws_client( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> ClientFixture: """Fixture for creating the test websocket client.""" diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index 6575cb8fdb2c..041920f653f8 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -1,8 +1,6 @@ """Test the api module.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, call -from aiohttp import ClientWebSocketResponse from matter_server.common.errors import InvalidCommand, NodeCommissionFailed import pytest @@ -10,13 +8,14 @@ from homeassistant.components.matter.api import ID, TYPE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_commission( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, matter_client: MagicMock, integration: MockConfigEntry, ) -> None: @@ -58,7 +57,7 @@ async def test_commission( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_commission_on_network( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, matter_client: MagicMock, integration: MockConfigEntry, ) -> None: @@ -100,7 +99,7 @@ async def test_commission_on_network( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_set_thread_dataset( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, matter_client: MagicMock, integration: MockConfigEntry, ) -> None: @@ -142,7 +141,7 @@ async def test_set_thread_dataset( @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_set_wifi_credentials( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, matter_client: MagicMock, integration: MockConfigEntry, ) -> None: diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index aea52cc30793..58d0d0d445bc 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -2,10 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, call, patch -from aiohttp import ClientWebSocketResponse from matter_server.client.exceptions import CannotConnect, InvalidServerVersion from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError @@ -28,6 +27,7 @@ from homeassistant.setup import async_setup_component from .common import load_and_parse_node_fixture, setup_integration_with_node_fixture from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator @pytest.fixture(name="connect_timeout") @@ -613,7 +613,7 @@ async def test_remove_entry( async def test_remove_config_entry_device( hass: HomeAssistant, matter_client: MagicMock, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test that a device can be removed ok.""" assert await async_setup_component(hass, "config", {}) @@ -656,7 +656,7 @@ async def test_remove_config_entry_device_no_node( hass: HomeAssistant, matter_client: MagicMock, integration: MockConfigEntry, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test that a device can be removed ok without an existing node.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 5d44cdbdb3cb..9d1867b31583 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -1,9 +1,6 @@ """Test function in __init__.py.""" from __future__ import annotations -from collections.abc import Awaitable, Callable - -from aiohttp import ClientWebSocketResponse from mysensors import BaseSyncGateway from mysensors.sensor import Sensor @@ -13,6 +10,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_remove_config_entry_device( @@ -20,7 +18,7 @@ async def test_remove_config_entry_device( gps_sensor: Sensor, integration: MockConfigEntry, gateway: BaseSyncGateway, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test that a device can be removed ok.""" entity_id = "sensor.gps_sensor_1_1" diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 874f9fbf49fd..9382b95521cc 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,5 +1,4 @@ """Tests for 1-Wire config flow.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch import aiohttp @@ -15,6 +14,8 @@ from homeassistant.setup import async_setup_component from . import setup_owproxy_mock_devices +from tests.typing import WebSocketGenerator + async def remove_device( ws_client: aiohttp.ClientWebSocketResponse, device_id: str, config_entry_id: str @@ -78,9 +79,7 @@ async def test_registry_cleanup( hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], + hass_ws_client: WebSocketGenerator, ): """Test being able to remove a disconnected device.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/pushover/test_init.py b/tests/components/pushover/test_init.py index 3d8837923e37..ef1413e40854 100644 --- a/tests/components/pushover/test_init.py +++ b/tests/components/pushover/test_init.py @@ -1,8 +1,6 @@ """Test pushbullet integration.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch -import aiohttp from pushover_complete import BadAPIRequestError import pytest import requests_mock @@ -17,6 +15,7 @@ from . import MOCK_CONFIG from tests.common import MockConfigEntry from tests.components.repairs import get_repairs +from tests.typing import WebSocketGenerator @pytest.fixture(autouse=False) @@ -30,9 +29,7 @@ def mock_pushover(): async def test_setup( hass: HomeAssistant, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], + hass_ws_client: WebSocketGenerator, mock_pushover: MagicMock, ) -> None: """Test integration failed due to an error.""" diff --git a/tests/components/repairs/__init__.py b/tests/components/repairs/__init__.py index 77971d0284ba..4d584da17063 100644 --- a/tests/components/repairs/__init__.py +++ b/tests/components/repairs/__init__.py @@ -1,15 +1,15 @@ """Tests for the repairs integration.""" -from collections.abc import Awaitable, Callable -from aiohttp import ClientWebSocketResponse from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.typing import WebSocketGenerator + async def get_repairs( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ): """Return the repairs list of issues.""" assert await async_setup_component(hass, "repairs", {}) diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index 0a085e382013..87acc96db230 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -1,8 +1,6 @@ """Test the repairs websocket API.""" -from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, Mock -from aiohttp import ClientWebSocketResponse from freezegun import freeze_time import pytest @@ -489,7 +487,7 @@ async def test_non_compliant_platform( @freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test sync method for creating and deleting an issue.""" diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index be50dba14b38..4db5b6a9d18e 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -1,12 +1,10 @@ """Test the repairs websocket API.""" from __future__ import annotations -from collections.abc import Awaitable, Callable from http import HTTPStatus from typing import Any from unittest.mock import ANY, AsyncMock, Mock -from aiohttp import ClientWebSocketResponse from freezegun import freeze_time import pytest import voluptuous as vol @@ -524,7 +522,7 @@ async def test_list_issues( async def test_fix_issue_aborted( hass: HomeAssistant, hass_client: ClientSessionGenerator, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can fix an issue.""" assert await async_setup_component(hass, "http", {}) diff --git a/tests/components/rtsp_to_webrtc/test_init.py b/tests/components/rtsp_to_webrtc/test_init.py index abbe3728a12d..a6d2d34b178c 100644 --- a/tests/components/rtsp_to_webrtc/test_init.py +++ b/tests/components/rtsp_to_webrtc/test_init.py @@ -3,7 +3,6 @@ from __future__ import annotations import base64 -from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import patch @@ -20,6 +19,7 @@ from .conftest import SERVER_URL, STREAM_SOURCE, ComponentSetup from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator # The webrtc component does not inspect the details of the offer and answer, # and is only a pass through. @@ -83,7 +83,7 @@ async def test_setup_communication_failure( async def test_offer_for_stream_source( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, mock_camera: Any, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, @@ -124,7 +124,7 @@ async def test_offer_for_stream_source( async def test_offer_failure( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, mock_camera: Any, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, @@ -161,7 +161,7 @@ async def test_no_stun_server( hass: HomeAssistant, rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, - hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test successful setup and unload.""" await setup_integration() @@ -188,7 +188,7 @@ async def test_stun_server( rtsp_to_webrtc_client: Any, setup_integration: ComponentSetup, config_entry: MockConfigEntry, - hass_ws_client: Callable[[...], Awaitable[aiohttp.ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test successful setup and unload.""" await setup_integration() diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index a2089240bfda..70ba6dfde3c7 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -1,11 +1,10 @@ """Test for the Schedule integration.""" from __future__ import annotations -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Callable, Coroutine from typing import Any from unittest.mock import patch -from aiohttp import ClientWebSocketResponse import pytest from homeassistant.components.schedule import STORAGE_VERSION, STORAGE_VERSION_MINOR @@ -39,6 +38,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockUser, async_capture_events, async_fire_time_changed +from tests.typing import WebSocketGenerator @pytest.fixture @@ -537,7 +537,7 @@ async def test_schedule_updates( async def test_ws_list( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], ) -> None: """Test listing via WS.""" @@ -567,7 +567,7 @@ async def test_ws_list( async def test_ws_delete( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], ) -> None: """Test WS delete cleans up entity registry.""" @@ -602,7 +602,7 @@ async def test_ws_delete( ) async def test_update( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], to: str, next_event: str, @@ -672,7 +672,7 @@ async def test_update( ) async def test_ws_create( hass: HomeAssistant, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, schedule_setup: Callable[..., Coroutine[Any, Any, bool]], freezer, to: str, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 2ff0ebe85021..caa77e8408dd 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, patch import aiohttp @@ -23,6 +22,7 @@ from . import _patch_discovery from .utils import MockUFPFixture, init_entry, time_changed from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def remove_device( @@ -217,9 +217,7 @@ async def test_device_remove_devices( hass: HomeAssistant, ufp: MockUFPFixture, light: Light, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a device that no longer exists.""" @@ -252,9 +250,7 @@ async def test_device_remove_devices( async def test_device_remove_devices_nvr( hass: HomeAssistant, ufp: MockUFPFixture, - hass_ws_client: Callable[ - [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] - ], + hass_ws_client: WebSocketGenerator, ) -> None: """Test we can only remove a NVR device that no longer exists.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 29838145db2e..d0546b6a2efd 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -1,8 +1,6 @@ """The tests for the Update component.""" -from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, patch -from aiohttp import ClientWebSocketResponse import pytest from homeassistant.components.update import ( @@ -40,6 +38,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from tests.common import MockEntityPlatform, mock_restore_cache +from tests.typing import WebSocketGenerator class MockUpdateEntity(UpdateEntity): @@ -681,7 +680,7 @@ async def test_restore_state( async def test_release_notes( hass: HomeAssistant, enable_custom_integrations: None, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test getting the release notes over the websocket connection.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -707,7 +706,7 @@ async def test_release_notes( async def test_release_notes_entity_not_found( hass: HomeAssistant, enable_custom_integrations: None, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test getting the release notes for not found entity.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -734,7 +733,7 @@ async def test_release_notes_entity_not_found( async def test_release_notes_entity_does_not_support_release_notes( hass: HomeAssistant, enable_custom_integrations: None, - hass_ws_client: Callable[[HomeAssistant], Awaitable[ClientWebSocketResponse]], + hass_ws_client: WebSocketGenerator, ) -> None: """Test getting the release notes for entity that does not support release notes.""" platform = getattr(hass.components, f"test.{DOMAIN}") From d1969fd0c222550537ec8def29c68d1e010ca87f Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Tue, 14 Mar 2023 19:26:05 +0100 Subject: [PATCH 0474/1058] Add water sensor to bthome (#89595) * Add water sensor to bthome * Use TOTAL state class for gas water and energy --- homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 14 +++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/test_sensor.py | 23 ++++++++++++++++--- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index da8f719bf70e..87a84e5fab00 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==2.8.0"] + "requirements": ["bthome-ble==2.9.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 981639573070..9b5def30054c 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -117,7 +117,7 @@ SENSOR_DESCRIPTIONS = { key=f"{BTHomeSensorDeviceClass.ENERGY}_{Units.ENERGY_KILO_WATT_HOUR}", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), # Gas (m3) ( @@ -127,7 +127,7 @@ SENSOR_DESCRIPTIONS = { key=f"{BTHomeSensorDeviceClass.GAS}_{Units.VOLUME_CUBIC_METERS}", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), # Humidity in (percent) (BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( @@ -297,6 +297,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), + # Water (L) + ( + BTHomeSensorDeviceClass.WATER, + Units.VOLUME_LITERS, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.WATER}_{Units.VOLUME_LITERS}", + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.TOTAL, + ), } diff --git a/requirements_all.txt b/requirements_all.txt index 9aa47dde7f0d..fb6a0c4e600e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -492,7 +492,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.8.0 +bthome-ble==2.9.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 2844a2abe751..df875eccea6d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -402,7 +402,7 @@ brother==2.3.0 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.8.0 +bthome-ble==2.9.0 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index af01db0a6ef7..7893ad3cb440 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -188,7 +188,7 @@ _LOGGER = logging.getLogger(__name__) "sensor_entity": "sensor.test_device_18b2_energy", "friendly_name": "Test Device 18B2 Energy", "unit_of_measurement": "kWh", - "state_class": "total_increasing", + "state_class": "total", "expected_state": "1346.067", }, ], @@ -542,7 +542,7 @@ async def test_v1_sensors( "sensor_entity": "sensor.test_device_18b2_energy", "friendly_name": "Test Device 18B2 Energy", "unit_of_measurement": "kWh", - "state_class": "total_increasing", + "state_class": "total", "expected_state": "1346.067", }, ], @@ -856,11 +856,28 @@ async def test_v1_sensors( "sensor_entity": "sensor.test_device_18b2_gas", "friendly_name": "Test Device 18B2 Gas", "unit_of_measurement": "m³", - "state_class": "total_increasing", + "state_class": "total", "expected_state": "1346.067", }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x4f\x87\x56\x2a\x01", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_water", + "friendly_name": "Test Device 18B2 Water", + "unit_of_measurement": "L", + "state_class": "total", + "expected_state": "19551.879", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( From 9d2c62095f89ff93c4f62cab204e65b09dbcd1e7 Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Tue, 14 Mar 2023 15:44:55 -0300 Subject: [PATCH 0475/1058] Bump bond-async to 0.1.23 (#89697) --- homeassistant/components/bond/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index bf343673fd6f..fc91f8eb72ef 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["bond_async"], "quality_scale": "platinum", - "requirements": ["bond-async==0.1.22"], + "requirements": ["bond-async==0.1.23"], "zeroconf": ["_bond._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index fb6a0c4e600e..0831d5155341 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -467,7 +467,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 # homeassistant.components.bond -bond-async==0.1.22 +bond-async==0.1.23 # homeassistant.components.bosch_shc boschshcpy==0.2.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df875eccea6d..a192592ca271 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -387,7 +387,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 # homeassistant.components.bond -bond-async==0.1.22 +bond-async==0.1.23 # homeassistant.components.bosch_shc boschshcpy==0.2.35 From a6d6807dd0db5631e711cb24de1e52cb7a95d1cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Mar 2023 09:06:56 -1000 Subject: [PATCH 0476/1058] Add typing to statistics results (#89118) --- .../components/energy/websocket_api.py | 19 +++--- .../components/recorder/statistics.py | 65 ++++++++++++------- homeassistant/components/sensor/recorder.py | 12 ++-- homeassistant/components/tibber/sensor.py | 9 +-- 4 files changed, 63 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 2075d0000330..15ffc6a2804b 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -13,6 +13,7 @@ from typing import Any, cast import voluptuous as vol from homeassistant.components import recorder, websocket_api +from homeassistant.components.recorder.statistics import StatisticsRow from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.integration_platform import ( @@ -277,7 +278,7 @@ async def ws_get_fossil_energy_consumption( ) def _combine_sum_statistics( - stats: dict[str, list[dict[str, Any]]], statistic_ids: list[str] + stats: dict[str, list[StatisticsRow]], statistic_ids: list[str] ) -> dict[float, float]: """Combine multiple statistics, returns a dict indexed by start time.""" result: defaultdict[float, float] = defaultdict(float) @@ -313,11 +314,10 @@ async def ws_get_fossil_energy_consumption( if not stat_list: return result prev_stat: dict[str, Any] = stat_list[0] + fake_stat = {"start": stat_list[-1]["start"] + period.total_seconds()} # Loop over the hourly deltas + a fake entry to end the period - for statistic in chain( - stat_list, ({"start": stat_list[-1]["start"] + period.total_seconds()},) - ): + for statistic in chain(stat_list, (fake_stat,)): if not same_period(prev_stat["start"], statistic["start"]): start, _ = period_start_end(prev_stat["start"]) # The previous statistic was the last entry of the period @@ -338,10 +338,13 @@ async def ws_get_fossil_energy_consumption( statistics, msg["energy_statistic_ids"] ) energy_deltas = _calculate_deltas(merged_energy_statistics) - indexed_co2_statistics = { - period["start"]: period["mean"] - for period in statistics.get(msg["co2_statistic_id"], {}) - } + indexed_co2_statistics = cast( + dict[float, float], + { + period["start"]: period["mean"] + for period in statistics.get(msg["co2_statistic_id"], {}) + }, + ) # Calculate amount of fossil based energy, assume 100% fossil if missing fossil_energy = [ diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 473d416d7572..645b7d4f0427 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -14,7 +14,7 @@ from operator import itemgetter import os import re from statistics import mean -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text from sqlalchemy.engine import Engine @@ -166,6 +166,24 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { _LOGGER = logging.getLogger(__name__) +class BaseStatisticsRow(TypedDict, total=False): + """A processed row of statistic data.""" + + start: float + + +class StatisticsRow(BaseStatisticsRow, total=False): + """A processed row of statistic data.""" + + end: float + last_reset: float | None + state: float | None + sum: float | None + min: float | None + max: float | None + mean: float | None + + def _get_unit_class(unit: str | None) -> str | None: """Get corresponding unit class from from the statistics unit.""" if converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(unit): @@ -1048,14 +1066,14 @@ def list_statistic_ids( def _reduce_statistics( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], same_period: Callable[[float, float], bool], period_start_end: Callable[[float], tuple[float, float]], period: timedelta, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily or monthly statistics.""" - result: dict[str, list[dict[str, Any]]] = defaultdict(list) + result: dict[str, list[StatisticsRow]] = defaultdict(list) period_seconds = period.total_seconds() _want_mean = "mean" in types _want_min = "min" in types @@ -1067,16 +1085,15 @@ def _reduce_statistics( max_values: list[float] = [] mean_values: list[float] = [] min_values: list[float] = [] - prev_stat: dict[str, Any] = stat_list[0] + prev_stat: StatisticsRow = stat_list[0] + fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds} # Loop over the hourly statistics + a fake entry to end the period - for statistic in chain( - stat_list, ({"start": stat_list[-1]["start"] + period_seconds},) - ): + for statistic in chain(stat_list, (fake_entry,)): if not same_period(prev_stat["start"], statistic["start"]): start, end = period_start_end(prev_stat["start"]) # The previous statistic was the last entry of the period - row: dict[str, Any] = { + row: StatisticsRow = { "start": start, "end": end, } @@ -1146,9 +1163,9 @@ def reduce_day_ts_factory() -> ( def _reduce_statistics_per_day( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to daily statistics.""" _same_day_ts, _day_start_end_ts = reduce_day_ts_factory() return _reduce_statistics( @@ -1196,9 +1213,9 @@ def reduce_week_ts_factory() -> ( def _reduce_statistics_per_week( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to weekly statistics.""" _same_week_ts, _week_start_end_ts = reduce_week_ts_factory() return _reduce_statistics( @@ -1248,9 +1265,9 @@ def reduce_month_ts_factory() -> ( def _reduce_statistics_per_month( - stats: dict[str, list[dict[str, Any]]], + stats: dict[str, list[StatisticsRow]], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Reduce hourly statistics to monthly statistics.""" _same_month_ts, _month_start_end_ts = reduce_month_ts_factory() return _reduce_statistics( @@ -1724,7 +1741,7 @@ def _statistics_during_period_with_session( period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. If end_time is omitted, returns statistics newer than or equal to start_time. @@ -1808,7 +1825,7 @@ def statistics_during_period( period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict[str, Any]]]: +) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. If end_time is omitted, returns statistics newer than or equal to start_time. @@ -1863,7 +1880,7 @@ def _get_last_statistics( convert_units: bool, table: type[StatisticsBase], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats statistics for a given statistic_id.""" statistic_ids = [statistic_id] with session_scope(hass=hass, read_only=True) as session: @@ -1902,7 +1919,7 @@ def get_last_statistics( statistic_id: str, convert_units: bool, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats statistics for a statistic_id.""" return _get_last_statistics( hass, number_of_stats, statistic_id, convert_units, Statistics, types @@ -1915,7 +1932,7 @@ def get_last_short_term_statistics( statistic_id: str, convert_units: bool, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats short term statistics for a statistic_id.""" return _get_last_statistics( hass, number_of_stats, statistic_id, convert_units, StatisticsShortTerm, types @@ -1951,7 +1968,7 @@ def get_latest_short_term_statistics( statistic_ids: list[str], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], metadata: dict[str, tuple[int, StatisticMetaData]] | None = None, -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Return the latest short term statistics for a list of statistic_ids.""" with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_ids @@ -2054,10 +2071,10 @@ def _sorted_statistics_to_dict( start_time: datetime | None, units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], -) -> dict[str, list[dict]]: +) -> dict[str, list[StatisticsRow]]: """Convert SQL results into JSON friendly data structure.""" assert stats, "stats must not be empty" # Guard against implementation error - result: dict = defaultdict(list) + result: dict[str, list[StatisticsRow]] = defaultdict(list) metadata = dict(_metadata.values()) need_stat_at_start_time: set[int] = set() start_time_ts = start_time.timestamp() if start_time else None @@ -2123,7 +2140,7 @@ def _sorted_statistics_to_dict( # attribute lookups, and dict lookups as much as possible. # for db_state in stats_list: - row: dict[str, Any] = { + row: StatisticsRow = { "start": (start_ts := db_state[start_ts_idx]), "end": start_ts + table_duration_seconds, } diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 0d2dc06b83f0..bd4facbea17d 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -529,11 +529,11 @@ def _compile_statistics( # noqa: C901 if entity_id in last_stats: # We have compiled history for this sensor before, # use that as a starting point. - last_reset = old_last_reset = _timestamp_to_isoformat_or_none( - last_stats[entity_id][0]["last_reset"] - ) - new_state = old_state = last_stats[entity_id][0]["state"] - _sum = last_stats[entity_id][0]["sum"] or 0.0 + last_stat = last_stats[entity_id][0] + last_reset = _timestamp_to_isoformat_or_none(last_stat["last_reset"]) + old_last_reset = last_reset + new_state = old_state = last_stat["state"] + _sum = last_stat["sum"] or 0.0 for fstate, state in fstates: reset = False @@ -596,7 +596,7 @@ def _compile_statistics( # noqa: C901 if reset: # The sensor has been reset, update the sum - if old_state is not None: + if old_state is not None and new_state is not None: _sum += new_state - old_state # ..and update the starting point new_state = fstate diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 7c563208720a..4d847c19205d 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -6,7 +6,7 @@ import datetime from datetime import timedelta import logging from random import randrange -from typing import Any +from typing import Any, cast import aiohttp import tibber @@ -614,7 +614,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): 5 * 365 * 24, production=is_production ) - _sum = 0 + _sum = 0.0 last_stats_time = None else: # hourly_consumption/production_data contains the last 30 days @@ -641,8 +641,9 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): None, {"sum"}, ) - _sum = stat[statistic_id][0]["sum"] - last_stats_time = stat[statistic_id][0]["start"] + first_stat = stat[statistic_id][0] + _sum = cast(float, first_stat["sum"]) + last_stats_time = first_stat["start"] statistics = [] From c2c809682ad01f5859aac8a8d655fe1f3efe0efa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Mar 2023 21:26:16 +0100 Subject: [PATCH 0477/1058] Tweak OTBR tests (#89694) --- tests/components/otbr/test_websocket_api.py | 118 +++----------------- 1 file changed, 13 insertions(+), 105 deletions(-) diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 04210a3433ef..844216225704 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -30,15 +30,9 @@ async def test_get_info( aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text=DATASET_CH16.hex()) - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/info", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["success"] assert msg["result"] == { "url": BASE_URL, @@ -54,15 +48,9 @@ async def test_get_info_no_entry( """Test async_get_info.""" await async_setup_component(hass, "otbr", {}) websocket_client = await hass_ws_client(hass) - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/info", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "not_loaded" @@ -74,21 +62,13 @@ async def test_get_info_fetch_fails( websocket_client, ) -> None: """Test async_get_info.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/info", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/info"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_dataset_failed" @@ -110,15 +90,9 @@ async def test_create_network( ) as get_active_dataset_tlvs_mock, patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" ) as mock_add: - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["success"] assert msg["result"] is None @@ -142,15 +116,9 @@ async def test_create_network_no_entry( """Test create network.""" await async_setup_component(hass, "otbr", {}) websocket_client = await hass_ws_client(hass) - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "not_loaded" @@ -162,21 +130,13 @@ async def test_create_network_fails_1( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.set_enabled", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "set_enabled_failed" @@ -188,23 +148,15 @@ async def test_create_network_fails_2( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.set_enabled", ), patch( "python_otbr_api.OTBR.create_active_dataset", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "create_active_dataset_failed" @@ -216,23 +168,15 @@ async def test_create_network_fails_3( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.set_enabled", side_effect=[None, python_otbr_api.OTBRError], ), patch( "python_otbr_api.OTBR.create_active_dataset", ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "set_enabled_failed" @@ -244,23 +188,15 @@ async def test_create_network_fails_4( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch("python_otbr_api.OTBR.set_enabled"), patch( "python_otbr_api.OTBR.create_active_dataset" ), patch( "python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_active_dataset_tlvs_failed" @@ -272,20 +208,12 @@ async def test_create_network_fails_5( websocket_client, ) -> None: """Test create network.""" - await async_setup_component(hass, "otbr", {}) - with patch("python_otbr_api.OTBR.set_enabled"), patch( "python_otbr_api.OTBR.create_active_dataset" ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=None): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/create_network", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/create_network"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_active_dataset_tlvs_empty" @@ -486,15 +414,9 @@ async def test_get_extended_address( "python_otbr_api.OTBR.get_extended_address", return_value=bytes.fromhex("4EF6C4F3FF750626"), ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/get_extended_address", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["success"] assert msg["result"] == {"extended_address": "4EF6C4F3FF750626".lower()} @@ -507,15 +429,9 @@ async def test_get_extended_address_no_entry( """Test get extended address.""" await async_setup_component(hass, "otbr", {}) websocket_client = await hass_ws_client(hass) - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/get_extended_address", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "not_loaded" @@ -527,20 +443,12 @@ async def test_get_extended_address_fetch_fails( websocket_client, ) -> None: """Test get extended address.""" - await async_setup_component(hass, "otbr", {}) - with patch( "python_otbr_api.OTBR.get_extended_address", side_effect=python_otbr_api.OTBRError, ): - await websocket_client.send_json( - { - "id": 5, - "type": "otbr/get_extended_address", - } - ) + await websocket_client.send_json_auto_id({"type": "otbr/get_extended_address"}) msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert not msg["success"] assert msg["error"]["code"] == "get_extended_address_failed" From 0630b7b9623e4ee543e5e0e61853384536458a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Mar 2023 10:31:31 -1000 Subject: [PATCH 0478/1058] Reduce size of load query to prime event_types and states_meta at startup (#89677) --- homeassistant/components/recorder/core.py | 8 ++++---- .../recorder/table_managers/event_types.py | 17 ++++++++++------- .../recorder/table_managers/states_meta.py | 17 +++++++++++------ 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 4ebd4703b65c..9eb1c6c166f2 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -793,13 +793,13 @@ class Recorder(threading.Thread): until its primed. """ assert self.event_session is not None - if hashes := [ + if hashes := { StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) for event in events if ( shared_attrs_bytes := self._serialize_state_attributes_from_event(event) ) - ]: + }: with self.event_session.no_autoflush: for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): for id_, shared_attrs in self.event_session.execute( @@ -815,11 +815,11 @@ class Recorder(threading.Thread): the data in the database for every event until its primed. """ assert self.event_session is not None - if hashes := [ + if hashes := { EventData.hash_shared_data_bytes(shared_event_bytes) for event in events if (shared_event_bytes := self._serialize_event_data_from_event(event)) - ]: + }: with self.event_session.no_autoflush: for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): for id_, shared_data in self.event_session.execute( diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 15dfff28b881..21bcf78bf1a9 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -9,8 +9,10 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event +from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids +from ..util import chunked CACHE_SIZE = 2048 @@ -27,7 +29,7 @@ class EventTypeManager: def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory.""" self.get_many( - (event.event_type for event in events if event.event_type is not None), + {event.event_type for event in events if event.event_type is not None}, session, ) @@ -51,12 +53,13 @@ class EventTypeManager: return results with session.no_autoflush: - for event_type_id, event_type in session.execute( - find_event_type_ids(missing) - ): - results[event_type] = self._id_map[event_type] = cast( - int, event_type_id - ) + for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): + for event_type_id, event_type in session.execute( + find_event_type_ids(missing_chunk) + ): + results[event_type] = self._id_map[event_type] = cast( + int, event_type_id + ) return results diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 8650df7c8b24..8af872ff969d 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -9,8 +9,10 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event +from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids +from ..util import chunked CACHE_SIZE = 8192 @@ -27,11 +29,11 @@ class StatesMetaManager: def load(self, events: list[Event], session: Session) -> None: """Load the entity_id to metadata_id mapping into memory.""" self.get_many( - ( + { event.data["new_state"].entity_id for event in events if event.data.get("new_state") is not None - ), + }, session, ) @@ -60,10 +62,13 @@ class StatesMetaManager: return results with session.no_autoflush: - for metadata_id, entity_id in session.execute( - find_states_metadata_ids(missing) - ): - results[entity_id] = self._id_map[entity_id] = cast(int, metadata_id) + for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): + for metadata_id, entity_id in session.execute( + find_states_metadata_ids(missing_chunk) + ): + results[entity_id] = self._id_map[entity_id] = cast( + int, metadata_id + ) return results From c33ca4f664a4e3eea801170be07a35b134f45e1d Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Wed, 15 Mar 2023 10:24:47 +1300 Subject: [PATCH 0479/1058] Add diagnostics to Starlink (#86328) --- homeassistant/components/starlink/__init__.py | 2 +- .../components/starlink/diagnostics.py | 21 ++++++ .../fixtures/status_data_success.json | 70 ++++++++++++++++++ tests/components/starlink/patchers.py | 9 +-- .../starlink/snapshots/test_diagnostics.ambr | 73 +++++++++++++++++++ tests/components/starlink/test_diagnostics.py | 34 +++++++++ 6 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/starlink/diagnostics.py create mode 100644 tests/components/starlink/fixtures/status_data_success.json create mode 100644 tests/components/starlink/snapshots/test_diagnostics.ambr create mode 100644 tests/components/starlink/test_diagnostics.py diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index ceb962c88cd9..c59269d2e077 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import StarlinkUpdateCoordinator -PLATFORMS: list[Platform] = [ +PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py new file mode 100644 index 000000000000..10711e7155e2 --- /dev/null +++ b/homeassistant/components/starlink/diagnostics.py @@ -0,0 +1,21 @@ +"""Fetches diagnostic data for Starlink systems.""" + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import StarlinkUpdateCoordinator + +TO_REDACT = {"id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Starlink config entries.""" + coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + return async_redact_data(asdict(coordinator.data), TO_REDACT) diff --git a/tests/components/starlink/fixtures/status_data_success.json b/tests/components/starlink/fixtures/status_data_success.json new file mode 100644 index 000000000000..e8cdc27e625e --- /dev/null +++ b/tests/components/starlink/fixtures/status_data_success.json @@ -0,0 +1,70 @@ +[ + { + "id": "ut00000000-00000000-000000aa", + "hardware_version": "rev3_proto2", + "software_version": "191e4dfa-d63a-46b1-a73b-9fa907733864.uterm.release", + "state": "CONNECTED", + "uptime": 804138, + "snr": null, + "seconds_to_first_nonempty_slot": 0.0, + "pop_ping_drop_rate": 0.0, + "downlink_throughput_bps": 10108.2724609375, + "uplink_throughput_bps": 11802.771484375, + "pop_ping_latency_ms": 30.285715103149414, + "alerts": 0, + "fraction_obstructed": 0.0, + "currently_obstructed": false, + "seconds_obstructed": null, + "obstruction_duration": null, + "obstruction_interval": null, + "direction_azimuth": -179.00344848632812, + "direction_elevation": 68.67173767089844, + "is_snr_above_noise_floor": true + }, + { + "wedges_fraction_obstructed[]": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "raw_wedges_fraction_obstructed[]": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "valid_s": 803872.0 + }, + { + "alert_motors_stuck": false, + "alert_thermal_throttle": false, + "alert_thermal_shutdown": false, + "alert_mast_not_near_vertical": false, + "alert_unexpected_location": false, + "alert_slow_ethernet_speeds": false, + "alert_roaming": false, + "alert_install_pending": false, + "alert_is_heating": false, + "alert_power_supply_thermal_throttle": false, + "alert_is_power_save_idle": false, + "alert_moving_while_not_mobile": false, + "alert_moving_fast_while_not_aviation": false + } +] diff --git a/tests/components/starlink/patchers.py b/tests/components/starlink/patchers.py index 0013b4e56ba5..dfc0d2415df6 100644 --- a/tests/components/starlink/patchers.py +++ b/tests/components/starlink/patchers.py @@ -1,7 +1,8 @@ """General Starlink patchers.""" +import json from unittest.mock import patch -from starlink_grpc import StatusDict +from tests.common import load_fixture SETUP_ENTRY_PATCHER = patch( "homeassistant.components.starlink.async_setup_entry", return_value=True @@ -9,11 +10,7 @@ SETUP_ENTRY_PATCHER = patch( COORDINATOR_SUCCESS_PATCHER = patch( "homeassistant.components.starlink.coordinator.status_data", - return_value=[ - StatusDict(id="1", software_version="1", hardware_version="1"), - {}, - {}, - ], + return_value=json.loads(load_fixture("status_data_success.json", "starlink")), ) DEVICE_FOUND_PATCHER = patch( diff --git a/tests/components/starlink/snapshots/test_diagnostics.ambr b/tests/components/starlink/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..6f859aaf50d5 --- /dev/null +++ b/tests/components/starlink/snapshots/test_diagnostics.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'alert': dict({ + 'alert_install_pending': False, + 'alert_is_heating': False, + 'alert_is_power_save_idle': False, + 'alert_mast_not_near_vertical': False, + 'alert_motors_stuck': False, + 'alert_moving_fast_while_not_aviation': False, + 'alert_moving_while_not_mobile': False, + 'alert_power_supply_thermal_throttle': False, + 'alert_roaming': False, + 'alert_slow_ethernet_speeds': False, + 'alert_thermal_shutdown': False, + 'alert_thermal_throttle': False, + 'alert_unexpected_location': False, + }), + 'obstruction': dict({ + 'raw_wedges_fraction_obstructed[]': list([ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ]), + 'valid_s': 803872.0, + 'wedges_fraction_obstructed[]': list([ + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ]), + }), + 'status': dict({ + 'alerts': 0, + 'currently_obstructed': False, + 'direction_azimuth': -179.00344848632812, + 'direction_elevation': 68.67173767089844, + 'downlink_throughput_bps': 10108.2724609375, + 'fraction_obstructed': 0.0, + 'hardware_version': 'rev3_proto2', + 'id': '**REDACTED**', + 'is_snr_above_noise_floor': True, + 'obstruction_duration': None, + 'obstruction_interval': None, + 'pop_ping_drop_rate': 0.0, + 'pop_ping_latency_ms': 30.285715103149414, + 'seconds_obstructed': None, + 'seconds_to_first_nonempty_slot': 0.0, + 'snr': None, + 'software_version': '191e4dfa-d63a-46b1-a73b-9fa907733864.uterm.release', + 'state': 'CONNECTED', + 'uplink_throughput_bps': 11802.771484375, + 'uptime': 804138, + }), + }) +# --- diff --git a/tests/components/starlink/test_diagnostics.py b/tests/components/starlink/test_diagnostics.py new file mode 100644 index 000000000000..4bf8a619c88b --- /dev/null +++ b/tests/components/starlink/test_diagnostics.py @@ -0,0 +1,34 @@ +"""Tests for Starlink diagnostics.""" +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.starlink.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant + +from .patchers import COORDINATOR_SUCCESS_PATCHER + +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, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, + ) + + with COORDINATOR_SUCCESS_PATCHER: + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diag == snapshot From 4ddcb140532f5771a2420a3ef52d066daede2fe5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 14 Mar 2023 17:27:38 -0700 Subject: [PATCH 0480/1058] Add additional CalendarEvent validation (#89533) Add additional event validation --- homeassistant/components/caldav/calendar.py | 6 + homeassistant/components/calendar/__init__.py | 194 +++++++++++------- tests/components/calendar/test_init.py | 13 +- tests/components/google/test_init.py | 2 +- 4 files changed, 133 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index ab3c47b96909..9a01cd2186ff 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -356,4 +356,10 @@ class WebDavCalendarData: else: enddate = obj.dtstart.value + timedelta(days=1) + # End date for an all day event is exclusive. This fixes the case where + # an all day event has a start and end values are the same, or the event + # has a zero duration. + if not isinstance(enddate, datetime) and obj.dtstart.value == enddate: + enddate += timedelta(days=1) + return enddate diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index c77d6c9c67a3..d09a389ce82a 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -67,6 +67,23 @@ SCAN_INTERVAL = datetime.timedelta(seconds=60) VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} +def _has_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Assert that all datetime values have a timezone.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Validate that all datetime values have a timezone.""" + for k in keys: + if ( + (value := obj.get(k)) + and isinstance(value, datetime.datetime) + and value.tzinfo is None + ): + raise vol.Invalid("Expected all values to have a timezone") + return obj + + return validate + + def _has_consistent_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: """Verify that all datetime values have a consistent timezone.""" @@ -89,7 +106,7 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]] """Convert all datetime values to the local timezone.""" def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys that are datetime values have the same timezone.""" + """Convert all keys that are datetime values to local timezone.""" for k in keys: if (value := obj.get(k)) and isinstance(value, datetime.datetime): obj[k] = dt.as_local(value) @@ -98,23 +115,59 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]] return validate -def _is_sorted(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that the specified values are sequential.""" +def _has_duration( + start_key: str, end_key: str +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that the time span between start and end is positive.""" def validate(obj: dict[str, Any]) -> dict[str, Any]: """Test that all keys in the dict are in order.""" - values = [] - for k in keys: - if not (value := obj.get(k)): - return obj - values.append(value) - if all(values) and values != sorted(values): - raise vol.Invalid(f"Values were not in order: {values}") + if (start := obj.get(start_key)) and (end := obj.get(end_key)): + duration = end - start + if duration.total_seconds() <= 0: + raise vol.Invalid(f"Expected positive event duration ({start}, {end})") return obj return validate +def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Verify that all values are of the same type.""" + + def validate(obj: dict[str, Any]) -> dict[str, Any]: + """Test that all keys in the dict have values of the same type.""" + uniq_values = groupby(type(obj[k]) for k in keys) + if len(list(uniq_values)) > 1: + raise vol.Invalid(f"Expected all values to be the same type: {keys}") + return obj + + return validate + + +def _validate_rrule(value: Any) -> str: + """Validate a recurrence rule string.""" + if value is None: + raise vol.Invalid("rrule value is None") + + if not isinstance(value, str): + raise vol.Invalid("rrule value expected a string") + + try: + rrulestr(value) + except ValueError as err: + raise vol.Invalid(f"Invalid rrule: {str(err)}") from err + + # Example format: FREQ=DAILY;UNTIL=... + rule_parts = dict(s.split("=", 1) for s in value.split(";")) + if not (freq := rule_parts.get("FREQ")): + raise vol.Invalid("rrule did not contain FREQ") + + if freq not in VALID_FREQS: + raise vol.Invalid(f"Invalid frequency for rule: {value}") + + return str(value) + + CREATE_EVENT_SERVICE = "create_event" CREATE_EVENT_SCHEMA = vol.All( cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), @@ -149,8 +202,42 @@ CREATE_EVENT_SCHEMA = vol.All( ), _has_consistent_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), _as_local_timezone(EVENT_START_DATETIME, EVENT_END_DATETIME), - _is_sorted(EVENT_START_DATE, EVENT_END_DATE), - _is_sorted(EVENT_START_DATETIME, EVENT_END_DATETIME), + _has_duration(EVENT_START_DATE, EVENT_END_DATE), + _has_duration(EVENT_START_DATETIME, EVENT_END_DATETIME), +) + +WEBSOCKET_EVENT_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), + vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_DESCRIPTION): cv.string, + vol.Optional(EVENT_RRULE): _validate_rrule, + }, + _has_same_type(EVENT_START, EVENT_END), + _has_consistent_timezone(EVENT_START, EVENT_END), + _as_local_timezone(EVENT_START, EVENT_END), + _has_duration(EVENT_START, EVENT_END), + ) +) + +# Validation for the CalendarEvent dataclass +CALENDAR_EVENT_SCHEMA = vol.Schema( + vol.All( + { + vol.Required("start"): vol.Any(cv.date, cv.datetime), + vol.Required("end"): vol.Any(cv.date, cv.datetime), + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_RRULE): _validate_rrule, + }, + _has_same_type("start", "end"), + _has_timezone("start", "end"), + _has_consistent_timezone("start", "end"), + _as_local_timezone("start", "end"), + _has_duration("start", "end"), + ), + extra=vol.ALLOW_EXTRA, ) @@ -243,6 +330,19 @@ class CalendarEvent: "all_day": self.all_day, } + def __post_init__(self) -> None: + """Perform validation on the CalendarEvent.""" + + def skip_none(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + return {k: v for k, v in obj if v is not None} + + try: + CALENDAR_EVENT_SCHEMA(dataclasses.asdict(self, dict_factory=skip_none)) + except vol.Invalid as err: + raise HomeAssistantError( + f"Failed to validate CalendarEvent: {err}" + ) from err + def _event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: """Convert CalendarEvent dataclass items to dictionary of attributes.""" @@ -316,30 +416,6 @@ def is_offset_reached( return start + offset_time <= dt.now(start.tzinfo) -def _validate_rrule(value: Any) -> str: - """Validate a recurrence rule string.""" - if value is None: - raise vol.Invalid("rrule value is None") - - if not isinstance(value, str): - raise vol.Invalid("rrule value expected a string") - - try: - rrulestr(value) - except ValueError as err: - raise vol.Invalid(f"Invalid rrule: {str(err)}") from err - - # Example format: FREQ=DAILY;UNTIL=... - rule_parts = dict(s.split("=", 1) for s in value.split(";")) - if not (freq := rule_parts.get("FREQ")): - raise vol.Invalid("rrule did not contain FREQ") - - if freq not in VALID_FREQS: - raise vol.Invalid(f"Invalid frequency for rule: {value}") - - return str(value) - - class CalendarEntity(Entity): """Base class for calendar event entities.""" @@ -447,6 +523,7 @@ class CalendarEventView(http.HomeAssistantView): request.app["hass"], start_date, end_date ) except HomeAssistantError as err: + _LOGGER.debug("Error reading events: %s", err) return self.json_message( f"Error reading events: {err}", HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -481,38 +558,11 @@ class CalendarListView(http.HomeAssistantView): return self.json(sorted(calendar_list, key=lambda x: cast(str, x["name"]))) -def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: - """Verify that all values are of the same type.""" - - def validate(obj: dict[str, Any]) -> dict[str, Any]: - """Test that all keys in the dict have values of the same type.""" - uniq_values = groupby(type(obj[k]) for k in keys) - if len(list(uniq_values)) > 1: - raise vol.Invalid(f"Expected all values to be the same type: {keys}") - return obj - - return validate - - @websocket_api.websocket_command( { vol.Required("type"): "calendar/event/create", vol.Required("entity_id"): cv.entity_id, - CONF_EVENT: vol.Schema( - vol.All( - { - vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), - vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), - vol.Required(EVENT_SUMMARY): cv.string, - vol.Optional(EVENT_DESCRIPTION): cv.string, - vol.Optional(EVENT_RRULE): _validate_rrule, - }, - _has_same_type(EVENT_START, EVENT_END), - _has_consistent_timezone(EVENT_START, EVENT_END), - _as_local_timezone(EVENT_START, EVENT_END), - _is_sorted(EVENT_START, EVENT_END), - ) - ), + CONF_EVENT: WEBSOCKET_EVENT_SCHEMA, } ) @websocket_api.async_response @@ -595,21 +645,7 @@ async def handle_calendar_event_delete( vol.Required(EVENT_UID): cv.string, vol.Optional(EVENT_RECURRENCE_ID): cv.string, vol.Optional(EVENT_RECURRENCE_RANGE): cv.string, - vol.Required(CONF_EVENT): vol.Schema( - vol.All( - { - vol.Required(EVENT_START): vol.Any(cv.date, cv.datetime), - vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), - vol.Required(EVENT_SUMMARY): cv.string, - vol.Optional(EVENT_DESCRIPTION): cv.string, - vol.Optional(EVENT_RRULE): _validate_rrule, - }, - _has_same_type(EVENT_START, EVENT_END), - _has_consistent_timezone(EVENT_START, EVENT_END), - _as_local_timezone(EVENT_START, EVENT_END), - _is_sorted(EVENT_START, EVENT_END), - ) - ), + vol.Required(CONF_EVENT): WEBSOCKET_EVENT_SCHEMA, } ) @websocket_api.async_response diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 5c90a1cfc2c6..875d5bf8c137 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -324,7 +324,7 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: "end_date_time": "2022-04-01T06:00:00", }, vol.error.MultipleInvalid, - "Values were not in order", + "Expected positive event duration", ), ( { @@ -332,7 +332,15 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: "end_date": "2022-04-01", }, vol.error.MultipleInvalid, - "Values were not in order", + "Expected positive event duration", + ), + ( + { + "start_date": "2022-04-01", + "end_date": "2022-04-01", + }, + vol.error.MultipleInvalid, + "Expected positive event duration", ), ], ids=[ @@ -351,6 +359,7 @@ async def test_unsupported_create_event_service(hass: HomeAssistant) -> None: "inconsistent_timezone", "incorrect_date_order", "incorrect_datetime_order", + "dates_not_exclusive", ], ) async def test_create_event_service_invalid_params( diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 28525acd4689..eac3bff58544 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -597,7 +597,7 @@ async def test_add_event_failure( with pytest.raises(HomeAssistantError): await add_event_call_service( - {"start_date": "2022-05-01", "end_date": "2022-05-01"} + {"start_date": "2022-05-01", "end_date": "2022-05-02"} ) From 858fc30fcd0db62ed40f609ec344acfd5eb482b3 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Wed, 15 Mar 2023 10:27:29 +0800 Subject: [PATCH 0481/1058] Fix infinite loop in sun.sun (#89723) --- homeassistant/helpers/sun.py | 11 ++++++++--- tests/helpers/test_sun.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 25bef38ed0b7..cf944dfc4794 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -82,7 +82,8 @@ def get_location_astral_event_next( kwargs["observer_elevation"] = elevation mod = -1 - while True: + first_err = None + while mod < 367: try: next_dt = ( cast(_AstralSunEventCallable, getattr(location, event))( @@ -94,9 +95,13 @@ def get_location_astral_event_next( ) if next_dt > utc_point_in_time: return next_dt - except ValueError: - pass + except ValueError as err: + if not first_err: + first_err = err mod += 1 + raise ValueError( + f"Unable to find event after one year, initial ValueError: {first_err}" + ) from first_err @callback diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index 86076221483b..e030958ab824 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -3,6 +3,8 @@ from datetime import datetime, timedelta from unittest.mock import patch +import pytest + from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import HomeAssistant import homeassistant.helpers.sun as sun @@ -192,3 +194,15 @@ def test_norway_in_june(hass: HomeAssistant) -> None: ) assert sun.get_astral_event_date(hass, SUN_EVENT_SUNRISE, june) is None assert sun.get_astral_event_date(hass, SUN_EVENT_SUNSET, june) is None + + +def test_impossible_elevation(hass: HomeAssistant) -> None: + """Test altitude where the sun can't set.""" + hass.config.latitude = 69.6 + hass.config.longitude = 18.8 + hass.config.elevation = 10000000 + + june = datetime(2016, 6, 1, tzinfo=dt_util.UTC) + + with pytest.raises(ValueError): + sun.get_astral_event_next(hass, SUN_EVENT_SUNRISE, june) From b906d67c1e88975e313ece4a0a81dfd0b007ac13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Mar 2023 16:33:19 -1000 Subject: [PATCH 0482/1058] Fix filtered purge not removing newer events (#89721) --- homeassistant/components/recorder/__init__.py | 12 +- homeassistant/components/recorder/core.py | 6 +- .../components/recorder/db_schema.py | 3 +- homeassistant/components/recorder/purge.py | 158 +++++++++++------- homeassistant/components/recorder/services.py | 3 +- homeassistant/components/recorder/tasks.py | 5 +- tests/components/recorder/test_init.py | 2 +- tests/components/recorder/test_purge.py | 148 ++++++++++++---- 8 files changed, 232 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 71795bfa6646..385c12f37a45 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -142,12 +142,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass_config_path=hass.config.path(DEFAULT_DB_FILE) ) exclude = conf[CONF_EXCLUDE] - exclude_t = exclude.get(CONF_EVENT_TYPES, []) - if EVENT_STATE_CHANGED in exclude_t: - _LOGGER.warning( - "State change events are excluded, recorder will not record state changes." - "This will become an error in Home Assistant Core 2022.2" - ) + exclude_event_types: set[str] = set(exclude.get(CONF_EVENT_TYPES, [])) + if EVENT_STATE_CHANGED in exclude_event_types: + _LOGGER.error("State change events cannot be excluded, use a filter instead") + exclude_event_types.remove(EVENT_STATE_CHANGED) instance = hass.data[DATA_INSTANCE] = Recorder( hass=hass, auto_purge=auto_purge, @@ -158,7 +156,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: db_max_retries=db_max_retries, db_retry_wait=db_retry_wait, entity_filter=entity_filter, - exclude_t=exclude_t, + exclude_event_types=exclude_event_types, exclude_attributes_by_domain=exclude_attributes_by_domain, ) instance.async_initialize() diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 9eb1c6c166f2..b57498e2987c 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -181,7 +181,7 @@ class Recorder(threading.Thread): db_max_retries: int, db_retry_wait: int, entity_filter: Callable[[str], bool], - exclude_t: list[str], + exclude_event_types: set[str], exclude_attributes_by_domain: dict[str, set[str]], ) -> None: """Initialize the recorder.""" @@ -214,7 +214,7 @@ class Recorder(threading.Thread): # it can be used to see if an entity is being recorded and is called # by is_entity_recorder and the sensor recorder. self.entity_filter = entity_filter - self.exclude_t = set(exclude_t) + self.exclude_event_types = exclude_event_types self.schema_version = 0 self._commits_without_expire = 0 @@ -388,7 +388,7 @@ class Recorder(threading.Thread): @callback def _async_event_filter(self, event: Event) -> bool: """Filter events.""" - if event.event_type in self.exclude_t: + if event.event_type in self.exclude_event_types: return False if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index a310161457bb..2fa3746a2c88 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -435,7 +435,8 @@ class States(Base): def __repr__(self) -> str: """Return string representation of instance for debugging.""" return ( - f"" diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index bb97448f1496..c644a17be0b8 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -1,21 +1,20 @@ """Purge old data helper.""" from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable from datetime import datetime from itertools import zip_longest import logging +import time from typing import TYPE_CHECKING from sqlalchemy.engine.row import Row from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import distinct -from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.util.dt as dt_util from .const import SQLITE_MAX_BIND_VARS -from .db_schema import Events, StateAttributes, States, StatesMeta +from .db_schema import Events, States, StatesMeta from .models import DatabaseEngine from .queries import ( attributes_ids_exist_in_states, @@ -144,11 +143,9 @@ def _purge_legacy_format( ) = _select_legacy_event_state_and_attributes_and_data_ids_to_purge( session, purge_before ) - if state_ids: - _purge_state_ids(instance, session, state_ids) + _purge_state_ids(instance, session, state_ids) _purge_unused_attributes_ids(instance, session, attributes_ids) - if event_ids: - _purge_event_ids(session, event_ids) + _purge_event_ids(session, event_ids) _purge_unused_data_ids(instance, session, data_ids) return bool(event_ids or state_ids or attributes_ids or data_ids) @@ -448,6 +445,8 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) -> None: """Disconnect states and delete by state id.""" + if not state_ids: + return # Update old_state_id to NULL before deleting to ensure # the delete does not fail due to a foreign key constraint @@ -559,8 +558,10 @@ def _purge_short_term_statistics( _LOGGER.debug("Deleted %s short term statistics", deleted_rows) -def _purge_event_ids(session: Session, event_ids: Iterable[int]) -> None: +def _purge_event_ids(session: Session, event_ids: set[int]) -> None: """Delete by event id.""" + if not event_ids: + return deleted_rows = session.execute(delete_event_rows(event_ids)) _LOGGER.debug("Deleted %s events", deleted_rows) @@ -619,9 +620,11 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: _LOGGER.debug("Cleanup filtered data") database_engine = instance.database_engine assert database_engine is not None + now_timestamp = time.time() # Check if excluded entity_ids are in database entity_filter = instance.entity_filter + has_more_states_to_purge = False excluded_metadata_ids: list[str] = [ metadata_id for (metadata_id, entity_id) in session.query( @@ -629,92 +632,123 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: ).all() if not entity_filter(entity_id) ] - if len(excluded_metadata_ids) > 0: - _purge_filtered_states( - instance, session, excluded_metadata_ids, database_engine + if excluded_metadata_ids: + has_more_states_to_purge = _purge_filtered_states( + instance, session, excluded_metadata_ids, database_engine, now_timestamp ) - return False # Check if excluded event_types are in database - excluded_event_types: list[str] = [ - event_type - for (event_type,) in session.query(distinct(Events.event_type)).all() - if event_type in instance.exclude_t - ] - if len(excluded_event_types) > 0: - _purge_filtered_events(instance, session, excluded_event_types) - return False + has_more_events_to_purge = False + if ( + event_type_to_event_type_ids := instance.event_type_manager.get_many( + instance.exclude_event_types, session + ) + ) and ( + excluded_event_type_ids := [ + event_type_id + for event_type_id in event_type_to_event_type_ids.values() + if event_type_id is not None + ] + ): + has_more_events_to_purge = _purge_filtered_events( + instance, session, excluded_event_type_ids, now_timestamp + ) - return True + # Purge has completed if there are not more state or events to purge + return not (has_more_states_to_purge or has_more_events_to_purge) def _purge_filtered_states( instance: Recorder, session: Session, - excluded_metadata_ids: list[str], + metadata_ids_to_purge: list[str], database_engine: DatabaseEngine, -) -> None: - """Remove filtered states and linked events.""" + purge_before_timestamp: float, +) -> bool: + """Remove filtered states and linked events. + + Return true if all states are purged + """ state_ids: tuple[int, ...] attributes_ids: tuple[int, ...] event_ids: tuple[int, ...] - state_ids, attributes_ids, event_ids = zip( - *( - session.query(States.state_id, States.attributes_id, States.event_id) - .filter(States.metadata_id.in_(excluded_metadata_ids)) - .limit(SQLITE_MAX_BIND_VARS) - .all() - ) + to_purge = list( + session.query(States.state_id, States.attributes_id, States.event_id) + .filter(States.metadata_id.in_(metadata_ids_to_purge)) + .filter(States.last_updated_ts < purge_before_timestamp) + .limit(SQLITE_MAX_BIND_VARS) + .all() ) - filtered_event_ids = [id_ for id_ in event_ids if id_ is not None] + if not to_purge: + return True + state_ids, attributes_ids, event_ids = zip(*to_purge) + filtered_event_ids = {id_ for id_ in event_ids if id_ is not None} _LOGGER.debug( "Selected %s state_ids to remove that should be filtered", len(state_ids) ) _purge_state_ids(instance, session, set(state_ids)) + # These are legacy events that are linked to a state that are no longer + # created but since we did not remove them when we stopped adding new ones + # we will need to purge them here. _purge_event_ids(session, filtered_event_ids) unused_attribute_ids_set = _select_unused_attributes_ids( session, {id_ for id_ in attributes_ids if id_ is not None}, database_engine ) _purge_batch_attributes_ids(instance, session, unused_attribute_ids_set) + return False def _purge_filtered_events( - instance: Recorder, session: Session, excluded_event_types: list[str] -) -> None: - """Remove filtered events and linked states.""" + instance: Recorder, + session: Session, + excluded_event_type_ids: list[int], + purge_before_timestamp: float, +) -> bool: + """Remove filtered events and linked states. + + Return true if all events are purged. + """ database_engine = instance.database_engine assert database_engine is not None - event_ids, data_ids = zip( - *( - session.query(Events.event_id, Events.data_id) - .filter(Events.event_type.in_(excluded_event_types)) - .limit(SQLITE_MAX_BIND_VARS) - .all() - ) + to_purge = list( + session.query(Events.event_id, Events.data_id) + .filter(Events.event_type_id.in_(excluded_event_type_ids)) + .filter(Events.time_fired_ts < purge_before_timestamp) + .limit(SQLITE_MAX_BIND_VARS) + .all() ) + if not to_purge: + return True + event_ids, data_ids = zip(*to_purge) + event_ids_set = set(event_ids) _LOGGER.debug( - "Selected %s event_ids to remove that should be filtered", len(event_ids) + "Selected %s event_ids to remove that should be filtered", len(event_ids_set) ) states: list[Row[tuple[int]]] = ( - session.query(States.state_id).filter(States.event_id.in_(event_ids)).all() + session.query(States.state_id).filter(States.event_id.in_(event_ids_set)).all() ) - state_ids: set[int] = {state.state_id for state in states} - _purge_state_ids(instance, session, state_ids) - _purge_event_ids(session, event_ids) + if states: + # These are legacy states that are linked to an event that are no longer + # created but since we did not remove them when we stopped adding new ones + # we will need to purge them here. + state_ids: set[int] = {state.state_id for state in states} + _purge_state_ids(instance, session, state_ids) + _purge_event_ids(session, event_ids_set) if unused_data_ids_set := _select_unused_event_data_ids( session, set(data_ids), database_engine ): _purge_batch_data_ids(instance, session, unused_data_ids_set) - if EVENT_STATE_CHANGED in excluded_event_types: - session.query(StateAttributes).delete(synchronize_session=False) - instance._state_attributes_ids = {} # pylint: disable=protected-access + return False -@retryable_database_job("purge") -def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) -> bool: +@retryable_database_job("purge_entity_data") +def purge_entity_data( + instance: Recorder, entity_filter: Callable[[str], bool], purge_before: datetime +) -> bool: """Purge states and events of specified entities.""" database_engine = instance.database_engine assert database_engine is not None + purge_before_timestamp = purge_before.timestamp() with session_scope(session=instance.get_session()) as session: selected_metadata_ids: list[str] = [ metadata_id @@ -724,12 +758,18 @@ def purge_entity_data(instance: Recorder, entity_filter: Callable[[str], bool]) if entity_filter(entity_id) ] _LOGGER.debug("Purging entity data for %s", selected_metadata_ids) - if len(selected_metadata_ids) > 0: - # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states - # or events record. - _purge_filtered_states( - instance, session, selected_metadata_ids, database_engine - ) + if not selected_metadata_ids: + return True + + # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states + # or events record. + if not _purge_filtered_states( + instance, + session, + selected_metadata_ids, + database_engine, + purge_before_timestamp, + ): _LOGGER.debug("Purging entity data hasn't fully completed yet") return False diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index 14337290c9b6..e1b2e388d6c9 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -71,7 +71,8 @@ def _async_register_purge_entities_service( domains = service.data.get(ATTR_DOMAINS, []) entity_globs = service.data.get(ATTR_ENTITY_GLOBS, []) entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs) - instance.queue_task(PurgeEntitiesTask(entity_filter)) + purge_before = dt_util.utcnow() + instance.queue_task(PurgeEntitiesTask(entity_filter, purge_before)) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 17b63aad2297..f2ba42bdea72 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -114,13 +114,14 @@ class PurgeEntitiesTask(RecorderTask): """Object to store entity information about purge task.""" entity_filter: Callable[[str], bool] + purge_before: datetime def run(self, instance: Recorder) -> None: """Purge entities from the database.""" - if purge.purge_entity_data(instance, self.entity_filter): + if purge.purge_entity_data(instance, self.entity_filter, self.purge_before): return # Schedule a new purge task if this one didn't finish - instance.queue_task(PurgeEntitiesTask(self.entity_filter)) + instance.queue_task(PurgeEntitiesTask(self.entity_filter, self.purge_before)) @dataclass diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index d6162dd20e20..5355931a76ab 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -100,7 +100,7 @@ def _default_recorder(hass): db_max_retries=10, db_retry_wait=3, entity_filter=CONFIG_SCHEMA({DOMAIN: {}}), - exclude_t=[], + exclude_event_types=set(), exclude_attributes_by_domain={}, ) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index b865af68dfd4..6594f0352a51 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -26,6 +26,7 @@ from homeassistant.components.recorder.db_schema import ( StatisticsShortTerm, ) from homeassistant.components.recorder.purge import purge_old_data +from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.services import ( SERVICE_PURGE, SERVICE_PURGE_ENTITIES, @@ -676,8 +677,8 @@ def _convert_pending_states_to_meta(instance: Recorder, session: Session) -> Non """Convert pending states to use states_metadata.""" entity_ids: set[str] = set() states: set[States] = set() + states_meta_objects: dict[str, StatesMeta] = {} for object in session: - states_meta_objects: dict[str, StatesMeta] = {} if isinstance(object, States): entity_ids.add(object.entity_id) states.add(object) @@ -697,6 +698,33 @@ def _convert_pending_states_to_meta(instance: Recorder, session: Session) -> Non state.states_meta_rel = states_meta_objects[entity_id] +def _convert_pending_events_to_event_types( + instance: Recorder, session: Session +) -> None: + """Convert pending events to use event_type_ids.""" + event_types: set[str] = set() + events: set[Events] = set() + event_types_objects: dict[str, EventTypes] = {} + for object in session: + if isinstance(object, Events): + event_types.add(object.event_type) + events.add(object) + + event_type_to_event_type_ids = instance.event_type_manager.get_many( + event_types, session + ) + + for event in events: + event_type = event.event_type + event.event_type = None + if event_type_id := event_type_to_event_type_ids.get(event_type): + event.event_type_id = event_type_id + continue + if event_type not in event_types_objects: + event_types_objects[event_type] = EventTypes(event_type=event_type) + event.event_type_rel = event_types_objects[event_type] + + @pytest.mark.parametrize("use_sqlite", (True, False), indirect=True) async def test_purge_filtered_states( async_setup_recorder_instance: RecorderInstanceGenerator, @@ -850,12 +878,24 @@ async def test_purge_filtered_states( ) assert states_sensor_excluded.count() == 0 - assert session.query(States).get(72).old_state_id is None - assert session.query(States).get(72).attributes_id == 71 - assert session.query(States).get(73).old_state_id is None - assert session.query(States).get(73).attributes_id == 71 + assert ( + session.query(States).filter(States.state_id == 72).first().old_state_id + is None + ) + assert ( + session.query(States).filter(States.state_id == 72).first().attributes_id + == 71 + ) + assert ( + session.query(States).filter(States.state_id == 73).first().old_state_id + is None + ) + assert ( + session.query(States).filter(States.state_id == 73).first().attributes_id + == 71 + ) - final_keep_state = session.query(States).get(74) + final_keep_state = session.query(States).filter(States.state_id == 74).first() assert final_keep_state.old_state_id == 62 # should have been kept assert final_keep_state.attributes_id == 71 @@ -867,7 +907,7 @@ async def test_purge_filtered_states( await async_wait_purge_done(hass) with session_scope(hass=hass) as session: - final_keep_state = session.query(States).get(74) + final_keep_state = session.query(States).filter(States.state_id == 74).first() assert final_keep_state.old_state_id == 62 # should have been kept assert final_keep_state.attributes_id == 71 @@ -1022,7 +1062,7 @@ async def test_purge_filtered_events( ) -> None: """Test filtered events are purged.""" config: ConfigType = {"exclude": {"event_types": ["EVENT_PURGE"]}} - await async_setup_recorder_instance(hass, config) + instance = await async_setup_recorder_instance(hass, config) def _add_db_entries(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: @@ -1050,14 +1090,17 @@ async def test_purge_filtered_events( timestamp, event_id, ) + _convert_pending_events_to_event_types(instance, session) service_data = {"keep_days": 10} _add_db_entries(hass) with session_scope(hass=hass) as session: - events_purge = session.query(Events).filter(Events.event_type == "EVENT_PURGE") + events_purge = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_PURGE",))) + ) events_keep = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids((EVENT_STATE_CHANGED,))) ) states = session.query(States) @@ -1073,9 +1116,11 @@ async def test_purge_filtered_events( await async_wait_purge_done(hass) with session_scope(hass=hass) as session: - events_purge = session.query(Events).filter(Events.event_type == "EVENT_PURGE") + events_purge = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_PURGE",))) + ) events_keep = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids((EVENT_STATE_CHANGED,))) ) states = session.query(States) assert events_purge.count() == 60 @@ -1094,9 +1139,11 @@ async def test_purge_filtered_events( await async_wait_purge_done(hass) with session_scope(hass=hass) as session: - events_purge = session.query(Events).filter(Events.event_type == "EVENT_PURGE") + events_purge = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_PURGE",))) + ) events_keep = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids((EVENT_STATE_CHANGED,))) ) states = session.query(States) assert events_purge.count() == 0 @@ -1109,10 +1156,18 @@ async def test_purge_filtered_events_state_changed( hass: HomeAssistant, ) -> None: """Test filtered state_changed events are purged. This should also remove all states.""" - config: ConfigType = {"exclude": {"event_types": [EVENT_STATE_CHANGED]}} + config: ConfigType = { + "exclude": { + "event_types": ["excluded_event"], + "entities": ["sensor.excluded", "sensor.old_format"], + } + } instance = await async_setup_recorder_instance(hass, config) # Assert entity_id is NOT excluded - assert instance.entity_filter("sensor.excluded") is True + assert instance.entity_filter("sensor.excluded") is False + assert instance.entity_filter("sensor.old_format") is False + assert instance.entity_filter("sensor.keep") is True + assert "excluded_event" in instance.exclude_event_types def _add_db_entries(hass: HomeAssistant) -> None: with session_scope(hass=hass) as session: @@ -1167,34 +1222,56 @@ async def test_purge_filtered_events_state_changed( old_state_id=62, # keep ) session.add_all((state_1, state_2, state_3)) + session.add( + Events( + event_id=231, + event_type="excluded_event", + event_data="{}", + origin="LOCAL", + time_fired_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + session.add( + States( + entity_id="sensor.old_format", + state="remove", + attributes="{}", + last_changed_ts=dt_util.utc_to_timestamp(timestamp), + last_updated_ts=dt_util.utc_to_timestamp(timestamp), + ) + ) + _convert_pending_events_to_event_types(instance, session) + _convert_pending_states_to_meta(instance, session) service_data = {"keep_days": 10, "apply_filter": True} _add_db_entries(hass) with session_scope(hass=hass) as session: - events_keep = session.query(Events).filter(Events.event_type == "EVENT_KEEP") + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) + ) events_purge = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids(("excluded_event",))) ) states = session.query(States) assert events_keep.count() == 10 - assert events_purge.count() == 60 - assert states.count() == 63 + assert events_purge.count() == 1 + assert states.count() == 64 await hass.services.async_call(recorder.DOMAIN, SERVICE_PURGE, service_data) await hass.async_block_till_done() - await async_recorder_block_till_done(hass) - await async_wait_purge_done(hass) - - await async_recorder_block_till_done(hass) - await async_wait_purge_done(hass) + for _ in range(4): + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) with session_scope(hass=hass) as session: - events_keep = session.query(Events).filter(Events.event_type == "EVENT_KEEP") + events_keep = session.query(Events).filter( + Events.event_type_id.in_(select_event_type_ids(("EVENT_KEEP",))) + ) events_purge = session.query(Events).filter( - Events.event_type == EVENT_STATE_CHANGED + Events.event_type_id.in_(select_event_type_ids(("excluded_event",))) ) states = session.query(States) @@ -1202,9 +1279,18 @@ async def test_purge_filtered_events_state_changed( assert events_purge.count() == 0 assert states.count() == 3 - assert session.query(States).get(61).old_state_id is None - assert session.query(States).get(62).old_state_id is None - assert session.query(States).get(63).old_state_id == 62 # should have been kept + assert ( + session.query(States).filter(States.state_id == 61).first().old_state_id + is None + ) + assert ( + session.query(States).filter(States.state_id == 62).first().old_state_id + is None + ) + assert ( + session.query(States).filter(States.state_id == 63).first().old_state_id + == 62 + ) # should have been kept async def test_purge_entities( @@ -1330,7 +1416,7 @@ async def test_purge_entities( _add_purge_records(hass) - # Confirm calling service without arguments matches all records (default filter behaviour) + # Confirm calling service without arguments matches all records (default filter behavior) with session_scope(hass=hass) as session: states = session.query(States) assert states.count() == 190 From a91055cc2af0fe7800b0f6663b8a433b3b01c556 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 14 Mar 2023 19:33:46 -0700 Subject: [PATCH 0483/1058] Fix additional typing in local calendar tests (#89704) * Fix additional typing in local calendar tests * Update tests/components/local_calendar/test_calendar.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- tests/components/local_calendar/conftest.py | 16 +++++++------- .../local_calendar/test_calendar.py | 21 +++++++++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index 02c984c284e9..bde9c226bacf 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -87,14 +87,14 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.async_block_till_done() -GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] +GetEventsFn = Callable[[str, str], Awaitable[list[dict[str, Any]]]] @pytest.fixture(name="get_events") def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: """Fetch calendar events from the HTTP API.""" - async def _fetch(start: str, end: str) -> None: + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: client = await hass_client() response = await client.get( f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" @@ -108,9 +108,7 @@ def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: def event_fields(data: dict[str, str]) -> dict[str, str]: """Filter event API response to minimum fields.""" return { - k: data.get(k) - for k in ["summary", "start", "end", "recurrence_id"] - if data.get(k) + k: data[k] for k in ["summary", "start", "end", "recurrence_id"] if data.get(k) } @@ -122,7 +120,9 @@ class Client: self.client = client self.id = 0 - async def cmd(self, cmd: str, payload: dict[str, Any] = None) -> dict[str, Any]: + async def cmd( + self, cmd: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: """Send a command and receive the json result.""" self.id += 1 await self.client.send_json( @@ -136,7 +136,9 @@ class Client: assert resp.get("id") == self.id return resp - async def cmd_result(self, cmd: str, payload: dict[str, Any] = None) -> Any: + async def cmd_result( + self, cmd: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any] | None: """Send a command and parse the result.""" resp = await self.cmd(cmd, payload) assert resp.get("success") diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 8364f6df6298..a859ed1d90c9 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -28,6 +28,7 @@ async def test_empty_calendar( assert len(events) == 0 state = hass.states.get(TEST_ENTITY) + assert state assert state.name == FRIENDLY_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { @@ -140,6 +141,7 @@ async def test_active_event( ) state = hass.states.get(TEST_ENTITY) + assert state assert state.name == FRIENDLY_NAME assert state.state == STATE_ON assert dict(state.attributes) == { @@ -176,6 +178,7 @@ async def test_upcoming_event( ) state = hass.states.get(TEST_ENTITY) + assert state assert state.name == FRIENDLY_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { @@ -642,9 +645,10 @@ async def test_invalid_rrule( }, }, ) + assert resp assert not resp.get("success") assert "error" in resp - assert resp.get("error").get("code") == "invalid_format" + assert resp["error"].get("code") == "invalid_format" @pytest.mark.parametrize( @@ -720,9 +724,10 @@ async def test_start_end_types( }, }, ) + assert result assert not result.get("success") assert "error" in result - assert "code" in result.get("error") + assert "code" in result["error"] assert result["error"]["code"] == "invalid_format" @@ -743,9 +748,10 @@ async def test_end_before_start( }, }, ) + assert result assert not result.get("success") assert "error" in result - assert "code" in result.get("error") + assert "code" in result["error"] assert result["error"]["code"] == "invalid_format" @@ -767,9 +773,10 @@ async def test_invalid_recurrence_rule( }, }, ) + assert result assert not result.get("success") assert "error" in result - assert "code" in result.get("error") + assert "code" in result["error"] assert result["error"]["code"] == "invalid_format" @@ -790,9 +797,10 @@ async def test_invalid_date_formats( }, }, ) + assert result assert not result.get("success") assert "error" in result - assert "code" in result.get("error") + assert "code" in result["error"] assert result["error"]["code"] == "invalid_format" @@ -815,9 +823,10 @@ async def test_update_invalid_event_id( }, }, ) + assert resp assert not resp.get("success") assert "error" in resp - assert resp.get("error").get("code") == "failed" + assert resp["error"].get("code") == "failed" @pytest.mark.parametrize( From bf8c4cae27fec62791316beb5ed977fb9823c209 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 15 Mar 2023 06:01:34 +0100 Subject: [PATCH 0484/1058] Update to nibe 2.1.4 (#89686) --- homeassistant/components/nibe_heatpump/config_flow.py | 1 + homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/config_flow.py b/homeassistant/components/nibe_heatpump/config_flow.py index 434a9a50ea68..6680ca6e325a 100644 --- a/homeassistant/components/nibe_heatpump/config_flow.py +++ b/homeassistant/components/nibe_heatpump/config_flow.py @@ -89,6 +89,7 @@ async def validate_nibegw_input( """Validate the user input allows us to connect.""" heatpump = HeatPump(Model[data[CONF_MODEL]]) + heatpump.word_swap = True await heatpump.initialize() connection = NibeGW( diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 5114cc222e91..81c23437bbc0 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.0.0"] + "requirements": ["nibe==2.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0831d5155341..526713c3a533 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1201,7 +1201,7 @@ nextcord==2.0.0a8 nextdns==1.3.0 # homeassistant.components.nibe_heatpump -nibe==2.0.0 +nibe==2.1.4 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a192592ca271..409b6d9257b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -894,7 +894,7 @@ nextcord==2.0.0a8 nextdns==1.3.0 # homeassistant.components.nibe_heatpump -nibe==2.0.0 +nibe==2.1.4 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 59de7f3057ec19dbef627ba06a376f1bb6307c24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Mar 2023 21:40:59 -1000 Subject: [PATCH 0485/1058] Migrate EventData management to a table manager (#89716) --- homeassistant/components/recorder/core.py | 102 ++++---------- homeassistant/components/recorder/purge.py | 17 +-- homeassistant/components/recorder/queries.py | 9 -- .../recorder/table_managers/__init__.py | 14 ++ .../recorder/table_managers/event_data.py | 128 ++++++++++++++++++ .../recorder/table_managers/event_types.py | 13 +- .../recorder/table_managers/states_meta.py | 12 +- 7 files changed, 184 insertions(+), 111 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/event_data.py diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index b57498e2987c..3e4f11dc95f6 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -80,15 +80,14 @@ from .models import ( from .pool import POOL_SIZE, MutexPool, RecorderPool from .queries import ( find_shared_attributes_id, - find_shared_data_id, get_shared_attributes, - get_shared_event_datas, has_entity_ids_to_migrate, has_event_type_to_migrate, has_events_context_ids_to_migrate, has_states_context_ids_to_migrate, ) from .run_history import RunHistory +from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager from .table_managers.states_meta import StatesMetaManager from .tasks import ( @@ -144,7 +143,6 @@ EXPIRE_AFTER_COMMITS = 120 # - How frequently states with overlapping attributes will change # - How much memory our low end hardware has STATE_ATTRIBUTES_ID_CACHE_SIZE = 2048 -EVENT_DATA_ID_CACHE_SIZE = 2048 SHUTDOWN_TASK = object() @@ -220,11 +218,10 @@ class Recorder(threading.Thread): self._commits_without_expire = 0 self._old_states: dict[str | None, States] = {} self._state_attributes_ids: LRU = LRU(STATE_ATTRIBUTES_ID_CACHE_SIZE) - self._event_data_ids: LRU = LRU(EVENT_DATA_ID_CACHE_SIZE) - self.event_type_manager = EventTypeManager() - self.states_meta_manager = StatesMetaManager() + self.event_data_manager = EventDataManager(self) + self.event_type_manager = EventTypeManager(self) + self.states_meta_manager = StatesMetaManager(self) self._pending_state_attributes: dict[str, StateAttributes] = {} - self._pending_event_data: dict[str, EventData] = {} self._pending_expunge: list[States] = [] self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None @@ -780,7 +777,7 @@ class Recorder(threading.Thread): assert self.event_session is not None self._pre_process_state_change_events(state_change_events) - self._pre_process_non_state_change_events(non_state_change_events) + self.event_data_manager.load(non_state_change_events, self.event_session) self.event_type_manager.load(non_state_change_events, self.event_session) self.states_meta_manager.load(state_change_events, self.event_session) @@ -807,26 +804,6 @@ class Recorder(threading.Thread): ).fetchall(): self._state_attributes_ids[shared_attrs] = id_ - def _pre_process_non_state_change_events(self, events: list[Event]) -> None: - """Load startup event attributes from the database. - - Since the _event_data_ids cache is empty at startup - we restore it from the database to avoid having to look up - the data in the database for every event until its primed. - """ - assert self.event_session is not None - if hashes := { - EventData.hash_shared_data_bytes(shared_event_bytes) - for event in events - if (shared_event_bytes := self._serialize_event_data_from_event(event)) - }: - with self.event_session.no_autoflush: - for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for id_, shared_data in self.event_session.execute( - get_shared_event_datas(hash_chunk) - ).fetchall(): - self._event_data_ids[shared_data] = id_ - def _guarded_process_one_task_or_recover(self, task: RecorderTask) -> None: """Process a task, guarding against exceptions to ensure the loop does not collapse.""" _LOGGER.debug("Processing task: %s", task) @@ -973,79 +950,51 @@ class Recorder(threading.Thread): return cast(int, attributes_id[0]) return None - def _find_shared_data_in_db(self, data_hash: int, shared_data: str) -> int | None: - """Find shared event data in the db from the hash and shared_attrs.""" - # - # Avoid the event session being flushed since it will - # commit all the pending events and states to the database. - # - # The lookup has already have checked to see if the data is cached - # or going to be written in the next commit so there is no - # need to flush before checking the database. - # - assert self.event_session is not None - with self.event_session.no_autoflush: - if data_id := self.event_session.execute( - find_shared_data_id(data_hash, shared_data) - ).first(): - return cast(int, data_id[0]) - return None - - def _serialize_event_data_from_event(self, event: Event) -> bytes | None: - """Serialize event data.""" - try: - return EventData.shared_data_bytes_from_event(event, self.dialect_name) - except JSON_ENCODE_EXCEPTIONS as ex: - _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) - return None - def _process_non_state_changed_event_into_session(self, event: Event) -> None: """Process any event into the session except state changed.""" - event_session = self.event_session - assert event_session is not None + session = self.event_session + assert session is not None dbevent = Events.from_event(event) # Map the event_type to the EventTypes table event_type_manager = self.event_type_manager if pending_event_types := event_type_manager.get_pending(event.event_type): dbevent.event_type_rel = pending_event_types - elif event_type_id := event_type_manager.get(event.event_type, event_session): + elif event_type_id := event_type_manager.get(event.event_type, session): dbevent.event_type_id = event_type_id else: event_types = EventTypes(event_type=event.event_type) event_type_manager.add_pending(event_types) - event_session.add(event_types) + session.add(event_types) dbevent.event_type_rel = event_types if not event.data: - event_session.add(dbevent) + session.add(dbevent) return - if not (shared_data_bytes := self._serialize_event_data_from_event(event)): + event_data_manager = self.event_data_manager + if not (shared_data_bytes := event_data_manager.serialize_from_event(event)): return # Map the event data to the EventData table shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit - if pending_event_data := self._pending_event_data.get(shared_data): + if pending_event_data := event_data_manager.get_pending(shared_data): dbevent.event_data_rel = pending_event_data # Matching attributes id found in the cache - elif data_id := self._event_data_ids.get(shared_data): + elif (data_id := event_data_manager.get_from_cache(shared_data)) or ( + (hash_ := EventData.hash_shared_data_bytes(shared_data_bytes)) + and (data_id := event_data_manager.get(shared_data, hash_, session)) + ): dbevent.data_id = data_id else: - data_hash = EventData.hash_shared_data_bytes(shared_data_bytes) - # Matching attributes found in the database - if data_id := self._find_shared_data_in_db(data_hash, shared_data): - self._event_data_ids[shared_data] = dbevent.data_id = data_id # No matching attributes found, save them in the DB - else: - dbevent_data = EventData(shared_data=shared_data, hash=data_hash) - dbevent.event_data_rel = self._pending_event_data[ - shared_data - ] = dbevent_data - event_session.add(dbevent_data) + dbevent_data = EventData(shared_data=shared_data, hash=hash_) + event_data_manager.add_pending(dbevent_data) + session.add(dbevent_data) + dbevent.event_data_rel = dbevent_data - event_session.add(dbevent) + session.add(dbevent) def _serialize_state_attributes_from_event(self, event: Event) -> bytes | None: """Serialize state changed event data.""" @@ -1184,9 +1133,7 @@ class Recorder(threading.Thread): state_attr.shared_attrs ] = state_attr.attributes_id self._pending_state_attributes = {} - for event_data in self._pending_event_data.values(): - self._event_data_ids[event_data.shared_data] = event_data.data_id - self._pending_event_data = {} + self.event_data_manager.post_commit_pending() self.event_type_manager.post_commit_pending() self.states_meta_manager.post_commit_pending() @@ -1212,9 +1159,8 @@ class Recorder(threading.Thread): """Close the event session.""" self._old_states.clear() self._state_attributes_ids.clear() - self._event_data_ids.clear() self._pending_state_attributes.clear() - self._pending_event_data.clear() + self.event_data_manager.reset() self.event_type_manager.reset() self.states_meta_manager.reset() diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index c644a17be0b8..5dffead59780 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -479,21 +479,6 @@ def _evict_purged_states_from_old_states_cache( old_states.pop(old_state_reversed[purged_state_id], None) -def _evict_purged_data_from_data_cache( - instance: Recorder, purged_data_ids: set[int] -) -> None: - """Evict purged data ids from the data ids cache.""" - # Make a map from data_id to the data json - event_data_ids = instance._event_data_ids # pylint: disable=protected-access - event_data_ids_reversed = { - data_id: data for data, data_id in event_data_ids.items() - } - - # Evict any purged data from the event_data_ids cache - for purged_attribute_id in purged_data_ids.intersection(event_data_ids_reversed): - event_data_ids.pop(event_data_ids_reversed[purged_attribute_id], None) - - def _evict_purged_attributes_from_attributes_cache( instance: Recorder, purged_attributes_ids: set[int] ) -> None: @@ -539,7 +524,7 @@ def _purge_batch_data_ids( _LOGGER.debug("Deleted %s data events", deleted_rows) # Evict any entries in the event_data_ids cache referring to a purged state - _evict_purged_data_from_data_cache(instance, data_ids) + instance.event_data_manager.evict_purged(data_ids) def _purge_statistics_runs(session: Session, statistics_runs: list[int]) -> None: diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 737faf2f7eca..0882da9d48c3 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -85,15 +85,6 @@ def find_shared_attributes_id( ) -def find_shared_data_id(attr_hash: int, shared_data: str) -> StatementLambdaElement: - """Find a data_id by hash and shared_data.""" - return lambda_stmt( - lambda: select(EventData.data_id) - .filter(EventData.hash == attr_hash) - .filter(EventData.shared_data == shared_data) - ) - - def _state_attrs_exist(attr: int | None) -> Select: """Check if a state attributes id exists in the states table.""" # https://github.com/sqlalchemy/sqlalchemy/issues/9189 diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py index c011520204b1..50ea8f0e11f1 100644 --- a/homeassistant/components/recorder/table_managers/__init__.py +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -1 +1,15 @@ """Managers for each table.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..core import Recorder + + +class BaseTableManager: + """Base class for table managers.""" + + def __init__(self, recorder: "Recorder") -> None: + """Initialize the table manager.""" + self.active = False + self.recorder = recorder diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py new file mode 100644 index 000000000000..975681b46673 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -0,0 +1,128 @@ +"""Support managing EventData.""" +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import TYPE_CHECKING, cast + +from lru import LRU # pylint: disable=no-name-in-module +from sqlalchemy.orm.session import Session + +from homeassistant.core import Event +from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS + +from . import BaseTableManager +from ..const import SQLITE_MAX_BIND_VARS +from ..db_schema import EventData +from ..queries import get_shared_event_datas +from ..util import chunked + +if TYPE_CHECKING: + from ..core import Recorder + + +CACHE_SIZE = 2048 + +_LOGGER = logging.getLogger(__name__) + + +class EventDataManager(BaseTableManager): + """Manage the EventData table.""" + + def __init__(self, recorder: Recorder) -> None: + """Initialize the event type manager.""" + self._id_map: dict[str, int] = LRU(CACHE_SIZE) + self._pending: dict[str, EventData] = {} + super().__init__(recorder) + self.active = True # always active + + def serialize_from_event(self, event: Event) -> bytes | None: + """Serialize event data.""" + try: + return EventData.shared_data_bytes_from_event( + event, self.recorder.dialect_name + ) + except JSON_ENCODE_EXCEPTIONS as ex: + _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) + return None + + def load(self, events: list[Event], session: Session) -> None: + """Load the shared_datas to data_ids mapping into memory from events.""" + if hashes := { + EventData.hash_shared_data_bytes(shared_event_bytes) + for event in events + if (shared_event_bytes := self.serialize_from_event(event)) + }: + self._load_from_hashes(hashes, session) + + def get(self, shared_data: str, data_hash: int, session: Session) -> int | None: + """Resolve shared_datas to the data_id.""" + return self.get_many(((shared_data, data_hash),), session)[shared_data] + + def get_from_cache(self, shared_data: str) -> int | None: + """Resolve shared_data to the data_id without accessing the underlying database.""" + return self._id_map.get(shared_data) + + def get_many( + self, shared_data_data_hashs: Iterable[tuple[str, int]], session: Session + ) -> dict[str, int | None]: + """Resolve shared_datas to data_ids.""" + results: dict[str, int | None] = {} + missing_hashes: set[int] = set() + for shared_data, data_hash in shared_data_data_hashs: + if (data_id := self._id_map.get(shared_data)) is None: + missing_hashes.add(data_hash) + + results[shared_data] = data_id + + if not missing_hashes: + return results + + return results | self._load_from_hashes(missing_hashes, session) + + def _load_from_hashes( + self, hashes: Iterable[int], session: Session + ) -> dict[str, int | None]: + """Load the shared_datas to data_ids mapping into memory from a list of hashes.""" + results: dict[str, int | None] = {} + with session.no_autoflush: + for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for data_id, shared_data in session.execute( + get_shared_event_datas(hashs_chunk) + ): + results[shared_data] = self._id_map[shared_data] = cast( + int, data_id + ) + + return results + + def get_pending(self, shared_data: str) -> EventData | None: + """Get pending EventData that have not be assigned ids yet.""" + return self._pending.get(shared_data) + + def add_pending(self, db_event_data: EventData) -> None: + """Add a pending EventData that will be committed at the next interval.""" + assert db_event_data.shared_data is not None + shared_data: str = db_event_data.shared_data + self._pending[shared_data] = db_event_data + + def post_commit_pending(self) -> None: + """Call after commit to load the data_ids of the new EventData into the LRU.""" + for shared_data, db_event_data in self._pending.items(): + self._id_map[shared_data] = db_event_data.data_id + self._pending.clear() + + def reset(self) -> None: + """Reset the event manager after the database has been reset or changed.""" + self._id_map.clear() + self._pending.clear() + + def evict_purged(self, data_ids: set[int]) -> None: + """Evict purged data_ids from the cache when they are no longer used.""" + id_map = self._id_map + event_data_ids_reversed = { + data_id: shared_data for shared_data, data_id in id_map.items() + } + # Evict any purged data from the cache + for purged_data_id in data_ids.intersection(event_data_ids_reversed): + id_map.pop(event_data_ids_reversed[purged_data_id], None) diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 21bcf78bf1a9..cc7490f8d73c 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -2,29 +2,34 @@ from __future__ import annotations from collections.abc import Iterable -from typing import cast +from typing import TYPE_CHECKING, cast from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event +from . import BaseTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids from ..util import chunked +if TYPE_CHECKING: + from ..core import Recorder + + CACHE_SIZE = 2048 -class EventTypeManager: +class EventTypeManager(BaseTableManager): """Manage the EventTypes table.""" - def __init__(self) -> None: + def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" self._id_map: dict[str, int] = LRU(CACHE_SIZE) self._pending: dict[str, EventTypes] = {} - self.active = False + super().__init__(recorder) def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory.""" diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 8af872ff969d..0352587aa60d 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -2,29 +2,33 @@ from __future__ import annotations from collections.abc import Iterable -from typing import cast +from typing import TYPE_CHECKING, cast from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event +from . import BaseTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids from ..util import chunked +if TYPE_CHECKING: + from ..core import Recorder + CACHE_SIZE = 8192 -class StatesMetaManager: +class StatesMetaManager(BaseTableManager): """Manage the StatesMeta table.""" - def __init__(self) -> None: + def __init__(self, recorder: Recorder) -> None: """Initialize the states meta manager.""" self._id_map: dict[str, int] = LRU(CACHE_SIZE) self._pending: dict[str, StatesMeta] = {} - self.active = False + super().__init__(recorder) def load(self, events: list[Event], session: Session) -> None: """Load the entity_id to metadata_id mapping into memory.""" From 9719f817c09197c6f47e1ec1a716d83eb7ff017e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 10:07:12 +0100 Subject: [PATCH 0486/1058] Bump ruff to 0.0.256 (#89734) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- tests/components/trace/test_websocket_api.py | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 269d786ab246..af0d3b318e50 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.254 + rev: v0.0.256 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 65913a315965..0b7c52f85dca 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -14,5 +14,5 @@ pycodestyle==2.10.0 pydocstyle==6.2.3 pyflakes==3.0.1 pyupgrade==3.3.1 -ruff==0.0.254 +ruff==0.0.256 yamllint==1.28.0 diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index fe9ee39a3670..5dbd78268e2f 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -1,7 +1,8 @@ """Test Trace websocket API.""" import asyncio +from collections import defaultdict import json -from typing import Any, DefaultDict +from typing import Any from unittest.mock import patch import pytest @@ -391,7 +392,7 @@ async def test_get_trace( trace_list = response["result"] # Get all traces and generate expected stored traces - traces = DefaultDict(list) + traces = defaultdict(list) for trace in trace_list: item_id = trace["item_id"] run_id = trace["run_id"] @@ -448,7 +449,7 @@ async def test_restore_traces( trace_list = response["result"] # Get all traces and generate expected stored traces - traces = DefaultDict(list) + traces = defaultdict(list) contexts = {} for trace in trace_list: item_id = trace["item_id"] From cd23caff58ccddd164a1e6e8d9e155b0e2f0194f Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 15 Mar 2023 12:27:45 +0100 Subject: [PATCH 0487/1058] Correct modbus serial method parameter (#89738) --- homeassistant/components/modbus/modbus.py | 9 +++++++-- tests/components/modbus/test_init.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 627950fe0033..abc1508d7dca 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -16,7 +16,7 @@ from pymodbus.client import ( from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException from pymodbus.pdu import ModbusResponse -from pymodbus.transaction import ModbusRtuFramer +from pymodbus.transaction import ModbusAsciiFramer, ModbusRtuFramer, ModbusSocketFramer import voluptuous as vol from homeassistant.const import ( @@ -279,9 +279,12 @@ class ModbusHub: } if self._config_type == SERIAL: # serial configuration + if client_config[CONF_METHOD] == "ascii": + self._pb_params["framer"] = ModbusAsciiFramer + else: + self._pb_params["framer"] = ModbusRtuFramer self._pb_params.update( { - "method": client_config[CONF_METHOD], "baudrate": client_config[CONF_BAUDRATE], "stopbits": client_config[CONF_STOPBITS], "bytesize": client_config[CONF_BYTESIZE], @@ -293,6 +296,8 @@ class ModbusHub: self._pb_params["host"] = client_config[CONF_HOST] if self._config_type == RTUOVERTCP: self._pb_params["framer"] = ModbusRtuFramer + else: + self._pb_params["framer"] = ModbusSocketFramer Defaults.Timeout = client_config[CONF_TIMEOUT] if CONF_MSG_WAIT in client_config: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 75f2f9d3e63a..7a0692340458 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -378,7 +378,7 @@ async def test_duplicate_entity_validator(do_config) -> None: CONF_TYPE: SERIAL, CONF_BAUDRATE: 9600, CONF_BYTESIZE: 8, - CONF_METHOD: "rtu", + CONF_METHOD: "ascii", CONF_PORT: TEST_PORT_SERIAL, CONF_PARITY: "E", CONF_STOPBITS: 1, From 6270776fbb573a62b1bcc83bc080768692e40abe Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 12:43:53 +0100 Subject: [PATCH 0488/1058] Add turn_on trigger to Samsung TV (#89018) * Add turn_on trigger to Samsung TV * Add tests * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Remove assert * Cleanup mock_send_magic_packet --------- Co-authored-by: Martin Hjelmare --- .../components/samsungtv/device_trigger.py | 80 +++++++ homeassistant/components/samsungtv/helpers.py | 61 ++++++ .../components/samsungtv/media_player.py | 22 +- .../components/samsungtv/strings.json | 5 + homeassistant/components/samsungtv/trigger.py | 46 ++++ .../components/samsungtv/triggers/__init__.py | 1 + .../components/samsungtv/triggers/turn_on.py | 108 ++++++++++ tests/components/samsungtv/conftest.py | 9 + .../samsungtv/test_device_trigger.py | 149 +++++++++++++ tests/components/samsungtv/test_trigger.py | 196 ++++++++++++++++++ 10 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/samsungtv/device_trigger.py create mode 100644 homeassistant/components/samsungtv/helpers.py create mode 100644 homeassistant/components/samsungtv/trigger.py create mode 100644 homeassistant/components/samsungtv/triggers/__init__.py create mode 100644 homeassistant/components/samsungtv/triggers/turn_on.py create mode 100644 tests/components/samsungtv/test_device_trigger.py create mode 100644 tests/components/samsungtv/test_trigger.py diff --git a/homeassistant/components/samsungtv/device_trigger.py b/homeassistant/components/samsungtv/device_trigger.py new file mode 100644 index 000000000000..f3a69e637e68 --- /dev/null +++ b/homeassistant/components/samsungtv/device_trigger.py @@ -0,0 +1,80 @@ +"""Provides device automations for control of Samsung TV.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import trigger +from .const import DOMAIN +from .helpers import ( + async_get_client_by_device_entry, + async_get_device_entry_by_device_id, +) +from .triggers.turn_on import ( + PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE, + async_get_turn_on_trigger, +) + +TRIGGER_TYPES = {TURN_ON_PLATFORM_TYPE} +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] == TURN_ON_PLATFORM_TYPE: + device_id = config[CONF_DEVICE_ID] + try: + device = async_get_device_entry_by_device_id(hass, device_id) + if DOMAIN in hass.data: + async_get_client_by_device_entry(hass, device) + except ValueError as err: + raise InvalidDeviceAutomationConfig(err) from err + + return config + + +async def async_get_triggers( + _hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device triggers for device.""" + triggers = [async_get_turn_on_trigger(device_id)] + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + if (trigger_type := config[CONF_TYPE]) == TURN_ON_PLATFORM_TYPE: + trigger_config = { + CONF_PLATFORM: trigger_type, + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + } + trigger_config = await trigger.async_validate_trigger_config( + hass, trigger_config + ) + return await trigger.async_attach_trigger( + hass, trigger_config, action, trigger_info + ) + + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") diff --git a/homeassistant/components/samsungtv/helpers.py b/homeassistant/components/samsungtv/helpers.py new file mode 100644 index 000000000000..06a3c3e70e1f --- /dev/null +++ b/homeassistant/components/samsungtv/helpers.py @@ -0,0 +1,61 @@ +"""Helper functions for Samsung TV.""" +from __future__ import annotations + +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 .bridge import SamsungTVBridge +from .const import DOMAIN + + +@callback +def async_get_device_entry_by_device_id( + hass: HomeAssistant, device_id: str +) -> DeviceEntry: + """Get Device Entry from Device Registry by device ID. + + Raises ValueError if device ID is invalid. + """ + device_reg = dr.async_get(hass) + if (device := device_reg.async_get(device_id)) is None: + raise ValueError(f"Device {device_id} is not a valid {DOMAIN} device.") + + return device + + +@callback +def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> str: + """Get device ID from an entity ID. + + Raises ValueError if entity or device ID is invalid. + """ + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(entity_id) + + if ( + entity_entry is None + or entity_entry.device_id is None + or entity_entry.platform != DOMAIN + ): + raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + + return entity_entry.device_id + + +@callback +def async_get_client_by_device_entry( + hass: HomeAssistant, device: DeviceEntry +) -> SamsungTVBridge: + """Get SamsungTVBridge from Device Registry by device entry. + + Raises ValueError if client is not found. + """ + domain_data: dict[str, SamsungTVBridge] = hass.data[DOMAIN] + for config_entry_id in device.config_entries: + if bridge := domain_data.get(config_entry_id): + return bridge + + raise ValueError( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 59b4131af150..ea31cca3e91d 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -40,6 +40,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script +from homeassistant.helpers.trigger import PluggableAction from homeassistant.util import dt as dt_util from .bridge import SamsungTVBridge, SamsungTVWSBridge @@ -51,6 +52,7 @@ from .const import ( DOMAIN, LOGGER, ) +from .triggers.turn_on import async_get_turn_on_trigger SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} @@ -112,6 +114,7 @@ class SamsungTVDevice(MediaPlayerEntity): self._ssdp_rendering_control_location: str | None = config_entry.data.get( CONF_SSDP_RENDERING_CONTROL_LOCATION ) + self._turn_on = PluggableAction(self.async_write_ha_state) self._on_script = on_script # Assume that the TV is in Play mode self._playing: bool = True @@ -125,8 +128,8 @@ class SamsungTVDevice(MediaPlayerEntity): self._app_list_event: asyncio.Event = asyncio.Event() self._attr_supported_features = SUPPORT_SAMSUNGTV - if self._on_script or self._mac: - # Add turn-on if on_script or mac is available + if self._turn_on or self._on_script or self._mac: + # Add turn-on if turn_on trigger or on_script YAML or mac is available self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET @@ -359,11 +362,23 @@ class SamsungTVDevice(MediaPlayerEntity): return False return ( self.state == MediaPlayerState.ON + or bool(self._turn_on) or self._on_script is not None or self._mac is not None or self._power_off_in_progress() ) + async def async_added_to_hass(self) -> None: + """Connect and subscribe to dispatcher signals and state updates.""" + await super().async_added_to_hass() + + if (entry := self.registry_entry) and entry.device_id: + self.async_on_remove( + self._turn_on.async_register( + self.hass, async_get_turn_on_trigger(entry.device_id) + ) + ) + async def async_turn_off(self) -> None: """Turn off media player.""" self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME @@ -448,6 +463,9 @@ class SamsungTVDevice(MediaPlayerEntity): async def async_turn_on(self) -> None: """Turn the media player on.""" + if self._turn_on: + await self._turn_on.async_run(self.hass, self._context) + # on_script is deprecated - replaced by turn_on trigger if self._on_script: await self._on_script.async_run(context=self._context) elif self._mac: diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index e67b50fae78c..f1f237fa4fb0 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -39,5 +39,10 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "device_automation": { + "trigger_type": { + "samsungtv.turn_on": "Device is requested to turn on" + } } } diff --git a/homeassistant/components/samsungtv/trigger.py b/homeassistant/components/samsungtv/trigger.py new file mode 100644 index 000000000000..cd78ff18be77 --- /dev/null +++ b/homeassistant/components/samsungtv/trigger.py @@ -0,0 +1,46 @@ +"""Samsung TV trigger dispatcher.""" +from __future__ import annotations + +from typing import cast + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) +from homeassistant.helpers.typing import ConfigType + +from .triggers import turn_on + +TRIGGERS = { + "turn_on": turn_on, +} + + +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: + """Return trigger platform.""" + platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) + if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: + raise ValueError(f"Unknown Samsung TV trigger platform {config[CONF_PLATFORM]}") + return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + platform = _get_trigger_platform(config) + return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach trigger of specified platform.""" + platform = _get_trigger_platform(config) + return await platform.async_attach_trigger(hass, config, action, trigger_info) diff --git a/homeassistant/components/samsungtv/triggers/__init__.py b/homeassistant/components/samsungtv/triggers/__init__.py new file mode 100644 index 000000000000..9e2e2af6d42a --- /dev/null +++ b/homeassistant/components/samsungtv/triggers/__init__.py @@ -0,0 +1 @@ +"""Samsung TV triggers.""" diff --git a/homeassistant/components/samsungtv/triggers/turn_on.py b/homeassistant/components/samsungtv/triggers/turn_on.py new file mode 100644 index 000000000000..de0036234ad1 --- /dev/null +++ b/homeassistant/components/samsungtv/triggers/turn_on.py @@ -0,0 +1,108 @@ +"""Samsung TV device turn on trigger.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.trigger import ( + PluggableAction, + TriggerActionType, + TriggerInfo, +) +from homeassistant.helpers.typing import ConfigType + +from ..const import DOMAIN +from ..helpers import ( + async_get_device_entry_by_device_id, + async_get_device_id_from_entity_id, +) + +# Platform type should be . +PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" + +TRIGGER_TYPE_TURN_ON = "turn_on" + +TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + }, + ), + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), +) + + +def async_get_turn_on_trigger(device_id: str) -> dict[str, str]: + """Return data for a turn on trigger.""" + + return { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: PLATFORM_TYPE, + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, + *, + platform_type: str = PLATFORM_TYPE, +) -> CALLBACK_TYPE | None: + """Attach a trigger.""" + device_ids = set() + if ATTR_DEVICE_ID in config: + device_ids.update(config.get(ATTR_DEVICE_ID, [])) + + if ATTR_ENTITY_ID in config: + device_ids.update( + { + async_get_device_id_from_entity_id(hass, entity_id) + for entity_id in config.get(ATTR_ENTITY_ID, []) + } + ) + + trigger_data = trigger_info["trigger_data"] + + unsubs = [] + + for device_id in device_ids: + device = async_get_device_entry_by_device_id(hass, device_id) + device_name = device.name_by_user or device.name + + variables = { + **trigger_data, + CONF_PLATFORM: platform_type, + ATTR_DEVICE_ID: device_id, + "description": f"Samsung turn on trigger for {device_name}", + } + + turn_on_trigger = async_get_turn_on_trigger(device_id) + + unsubs.append( + PluggableAction.async_attach_trigger( + hass, turn_on_trigger, action, {"trigger": variables} + ) + ) + + @callback + def async_remove() -> None: + """Remove state listeners async.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + return async_remove diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 0e95dfa28a9d..163805746425 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -20,10 +20,13 @@ from samsungtvws.exceptions import ResponseError from samsungtvws.remote import ChannelEmitCommand from homeassistant.components.samsungtv.const import WEBSOCKET_SSL_PORT +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.util.dt as dt_util from .const import SAMPLE_DEVICE_INFO_UE48JU6400, SAMPLE_DEVICE_INFO_WIFI +from tests.common import async_mock_service + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -307,3 +310,9 @@ def mac_address_fixture() -> Mock: """Patch getmac.get_mac_address.""" with patch("getmac.get_mac_address", return_value=None) as mac: yield mac + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") diff --git a/tests/components/samsungtv/test_device_trigger.py b/tests/components/samsungtv/test_device_trigger.py new file mode 100644 index 000000000000..1420440ad4ca --- /dev/null +++ b/tests/components/samsungtv/test_device_trigger.py @@ -0,0 +1,149 @@ +"""The tests for Samsung TV device triggers.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.samsungtv import DOMAIN, device_trigger +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import async_get as get_dev_reg +from homeassistant.setup import async_setup_component + +from . import setup_samsungtv_entry +from .test_media_player import ENTITY_ID, MOCK_ENTRYDATA_ENCRYPTED_WS + +from tests.common import MockConfigEntry, async_get_device_automations + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_get_triggers(hass: HomeAssistant) -> None: + """Test we get the expected triggers.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + device_reg = get_dev_reg(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN, "any")}) + + turn_on_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "samsungtv.turn_on", + "device_id": device.id, + "metadata": {}, + } + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert turn_on_trigger in triggers + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_if_fires_on_turn_on_request( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test for turn_on and turn_off triggers firing.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + device_reg = get_dev_reg(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN, "any")}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "samsungtv.turn_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.device_id }}", + "id": "{{ trigger.id }}", + }, + }, + }, + { + "trigger": { + "platform": "samsungtv.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + assert calls[1].data["some"] == ENTITY_ID + assert calls[1].data["id"] == 0 + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_failure_scenarios(hass: HomeAssistant) -> None: + """Test failure scenarios.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + # Test wrong trigger platform type + with pytest.raises(HomeAssistantError): + await device_trigger.async_attach_trigger( + hass, {"type": "wrong.type", "device_id": "invalid_device_id"}, None, {} + ) + + # Test invalid device id + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config( + hass, + { + "platform": "device", + "domain": DOMAIN, + "type": "samsungtv.turn_on", + "device_id": "invalid_device_id", + }, + ) + + entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + entry.add_to_hass(hass) + device_reg = get_dev_reg(hass) + + device = device_reg.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("fake", "fake")} + ) + + config = { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "samsungtv.turn_on", + } + + # Test that device id from non samsungtv domain raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config(hass, config) diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py new file mode 100644 index 000000000000..407d98186b17 --- /dev/null +++ b/tests/components/samsungtv/test_trigger.py @@ -0,0 +1,196 @@ +"""The tests for WebOS TV automation triggers.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.components.samsungtv import DOMAIN +from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import setup_samsungtv_entry +from .test_media_player import ENTITY_ID, MOCK_ENTRYDATA_ENCRYPTED_WS + +from tests.common import MockEntity, MockEntityPlatform + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_turn_on_trigger_device_id( + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry +) -> None: + """Test for turn_on triggers by device_id firing.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + device = device_registry.async_get_device(identifiers={(DOMAIN, "any")}) + assert device, repr(device_registry.devices) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "samsungtv.turn_on", + "device_id": device.id, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": device.id, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + + with patch("homeassistant.config.load_yaml", return_value={}): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) + + calls.clear() + + with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_turn_on_trigger_entity_id( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test for turn_on triggers by entity_id firing.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "samsungtv.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == ENTITY_ID + assert calls[0].data["id"] == 0 + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_wrong_trigger_platform_type( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test wrong trigger platform type.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "samsungtv.wrong_type", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + assert ( + "ValueError: Unknown Samsung TV trigger platform samsungtv.wrong_type" + in caplog.text + ) + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_trigger_invalid_entity_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test turn on trigger using invalid entity_id.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + platform = MockEntityPlatform(hass) + + invalid_entity = f"{DOMAIN}.invalid" + await platform.async_add_entities([MockEntity(name=invalid_entity)]) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "samsungtv.turn_on", + "entity_id": invalid_entity, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + assert ( + f"ValueError: Entity {invalid_entity} is not a valid samsungtv entity" + in caplog.text + ) From 6a01c3369d375d3e865c33a695608fb7a4dbd410 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 15 Mar 2023 12:56:01 +0100 Subject: [PATCH 0489/1058] Reolink auto quick reply (#89656) --- homeassistant/components/reolink/number.py | 13 +++++++++++++ homeassistant/components/reolink/select.py | 12 ++++++++++++ homeassistant/components/reolink/strings.json | 5 +++++ 3 files changed, 30 insertions(+) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 616fc8b74c16..cab925d41fed 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -175,6 +175,19 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), ), + ReolinkNumberEntityDescription( + key="auto_quick_reply_time", + name="Auto quick reply time", + icon="mdi:message-reply-text-outline", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "quick_reply"), + value=lambda api, ch: api.quick_reply_time(ch), + method=lambda api, ch, value: api.set_quick_reply(ch, time=int(value)), + ), ) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index c7bd621a4bc3..e18961c97d43 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -67,6 +67,18 @@ SELECT_ENTITIES = ( supported=lambda api, ch: api.supported(ch, "ptz_presets"), method=lambda api, ch, name: api.set_ptz_command(ch, preset=name), ), + ReolinkSelectEntityDescription( + key="auto_quick_reply_message", + name="Auto quick reply message", + icon="mdi:message-reply-text-outline", + translation_key="auto_quick_reply_message", + get_options=lambda api, ch: list(api.quick_reply_dict(ch).values()), + supported=lambda api, ch: api.supported(ch, "quick_reply"), + value=lambda api, ch: api.quick_reply_dict(ch)[api.quick_reply_file(ch)], + method=lambda api, ch, mess: api.set_quick_reply( + ch, file_id=[k for k, v in api.quick_reply_dict(ch).items() if v == mess][0] + ), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index c86917b4de27..5047c4f2713e 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -63,6 +63,11 @@ "color": "Color", "blackwhite": "Black&White" } + }, + "auto_quick_reply_message": { + "state": { + "off": "Off" + } } } } From a244749712705c798e3f56278d8be5917506c364 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 02:54:02 -1000 Subject: [PATCH 0490/1058] Make StatesMetaManager thread-safe when an entity_id is fully deleted from the database and than re-added (#89732) * refactor to make StatesMetaManager threadsafe * refactor to make StatesMetaManager threadsafe * refactor to make StatesMetaManager threadsafe * refactor to make StatesMetaManager threadsafe * reduce * comments --- homeassistant/components/logbook/processor.py | 2 +- homeassistant/components/recorder/core.py | 2 +- .../components/recorder/history/modern.py | 8 +- .../components/recorder/migration.py | 4 +- .../recorder/table_managers/event_data.py | 60 ++++++++++--- .../recorder/table_managers/event_types.py | 48 +++++++++-- .../recorder/table_managers/states_meta.py | 86 +++++++++++++++---- tests/components/recorder/test_migrate.py | 2 +- tests/components/recorder/test_purge.py | 3 +- tests/components/recorder/test_util.py | 2 +- 10 files changed, 175 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index cd88dcb73aa4..e053dcb08191 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -155,7 +155,7 @@ class EventProcessor: if self.entity_ids: instance = get_instance(self.hass) entity_id_to_metadata_id = instance.states_meta_manager.get_many( - self.entity_ids, session + self.entity_ids, session, False ) metadata_ids = [ metadata_id diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 3e4f11dc95f6..26cd5c3b8896 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1027,7 +1027,7 @@ class Recorder(threading.Thread): states_meta_manager = self.states_meta_manager if pending_states_meta := states_meta_manager.get_pending(entity_id): dbstate.states_meta_rel = pending_states_meta - elif metadata_id := states_meta_manager.get(entity_id, event_session): + elif metadata_id := states_meta_manager.get(entity_id, event_session, True): dbstate.metadata_id = metadata_id else: states_meta = StatesMeta(entity_id=entity_id) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 416a83e8739c..6ac139cc7840 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -242,7 +242,7 @@ def get_significant_states_with_session( if entity_ids: instance = recorder.get_instance(hass) entity_id_to_metadata_id = instance.states_meta_manager.get_many( - entity_ids, session + entity_ids, session, False ) metadata_ids = [ metadata_id @@ -365,7 +365,7 @@ def state_changes_during_period( entity_id_to_metadata_id = None if entity_id: instance = recorder.get_instance(hass) - metadata_id = instance.states_meta_manager.get(entity_id, session) + metadata_id = instance.states_meta_manager.get(entity_id, session, False) entity_id_to_metadata_id = {entity_id: metadata_id} stmt = _state_changed_during_period_stmt( start_time, @@ -426,7 +426,9 @@ def get_last_state_changes( with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) - if not (metadata_id := instance.states_meta_manager.get(entity_id, session)): + if not ( + metadata_id := instance.states_meta_manager.get(entity_id, session, False) + ): return {} entity_id_to_metadata_id: dict[str, int | None] = {entity_id_lower: metadata_id} stmt = _get_last_state_changes_stmt(number_of_states, metadata_id) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 74fbcf307fb1..d594118ea545 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1457,7 +1457,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} - entity_id_to_metadata_id = states_meta_manager.get_many(entity_ids, session) + entity_id_to_metadata_id = states_meta_manager.get_many( + entity_ids, session, True + ) if missing_entity_ids := { # We should never see _EMPTY_ENTITY_ID in the states table # but we need to be defensive so we don't fail the migration diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index 975681b46673..c877f08f8784 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -47,7 +47,11 @@ class EventDataManager(BaseTableManager): return None def load(self, events: list[Event], session: Session) -> None: - """Load the shared_datas to data_ids mapping into memory from events.""" + """Load the shared_datas to data_ids mapping into memory from events. + + This call is not thread-safe and must be called from the + recorder thread. + """ if hashes := { EventData.hash_shared_data_bytes(shared_event_bytes) for event in events @@ -56,17 +60,29 @@ class EventDataManager(BaseTableManager): self._load_from_hashes(hashes, session) def get(self, shared_data: str, data_hash: int, session: Session) -> int | None: - """Resolve shared_datas to the data_id.""" + """Resolve shared_datas to the data_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self.get_many(((shared_data, data_hash),), session)[shared_data] def get_from_cache(self, shared_data: str) -> int | None: - """Resolve shared_data to the data_id without accessing the underlying database.""" + """Resolve shared_data to the data_id without accessing the underlying database. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self._id_map.get(shared_data) def get_many( self, shared_data_data_hashs: Iterable[tuple[str, int]], session: Session ) -> dict[str, int | None]: - """Resolve shared_datas to data_ids.""" + """Resolve shared_datas to data_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ results: dict[str, int | None] = {} missing_hashes: set[int] = set() for shared_data, data_hash in shared_data_data_hashs: @@ -83,7 +99,11 @@ class EventDataManager(BaseTableManager): def _load_from_hashes( self, hashes: Iterable[int], session: Session ) -> dict[str, int | None]: - """Load the shared_datas to data_ids mapping into memory from a list of hashes.""" + """Load the shared_datas to data_ids mapping into memory from a list of hashes. + + This call is not thread-safe and must be called from the + recorder thread. + """ results: dict[str, int | None] = {} with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): @@ -97,28 +117,48 @@ class EventDataManager(BaseTableManager): return results def get_pending(self, shared_data: str) -> EventData | None: - """Get pending EventData that have not be assigned ids yet.""" + """Get pending EventData that have not be assigned ids yet. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self._pending.get(shared_data) def add_pending(self, db_event_data: EventData) -> None: - """Add a pending EventData that will be committed at the next interval.""" + """Add a pending EventData that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ assert db_event_data.shared_data is not None shared_data: str = db_event_data.shared_data self._pending[shared_data] = db_event_data def post_commit_pending(self) -> None: - """Call after commit to load the data_ids of the new EventData into the LRU.""" + """Call after commit to load the data_ids of the new EventData into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ for shared_data, db_event_data in self._pending.items(): self._id_map[shared_data] = db_event_data.data_id self._pending.clear() def reset(self) -> None: - """Reset the event manager after the database has been reset or changed.""" + """Reset the event manager after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ self._id_map.clear() self._pending.clear() def evict_purged(self, data_ids: set[int]) -> None: - """Evict purged data_ids from the cache when they are no longer used.""" + """Evict purged data_ids from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ id_map = self._id_map event_data_ids_reversed = { data_id: shared_data for shared_data, data_id in id_map.items() diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index cc7490f8d73c..b31382336cc5 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -32,20 +32,32 @@ class EventTypeManager(BaseTableManager): super().__init__(recorder) def load(self, events: list[Event], session: Session) -> None: - """Load the event_type to event_type_ids mapping into memory.""" + """Load the event_type to event_type_ids mapping into memory. + + This call is not thread-safe and must be called from the + recorder thread. + """ self.get_many( {event.event_type for event in events if event.event_type is not None}, session, ) def get(self, event_type: str, session: Session) -> int | None: - """Resolve event_type to the event_type_id.""" + """Resolve event_type to the event_type_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self.get_many((event_type,), session)[event_type] def get_many( self, event_types: Iterable[str], session: Session ) -> dict[str, int | None]: - """Resolve event_types to event_type_ids.""" + """Resolve event_types to event_type_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ results: dict[str, int | None] = {} missing: list[str] = [] for event_type in event_types: @@ -69,27 +81,47 @@ class EventTypeManager(BaseTableManager): return results def get_pending(self, event_type: str) -> EventTypes | None: - """Get pending EventTypes that have not be assigned ids yet.""" + """Get pending EventTypes that have not be assigned ids yet. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self._pending.get(event_type) def add_pending(self, db_event_type: EventTypes) -> None: - """Add a pending EventTypes that will be committed at the next interval.""" + """Add a pending EventTypes that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ assert db_event_type.event_type is not None event_type: str = db_event_type.event_type self._pending[event_type] = db_event_type def post_commit_pending(self) -> None: - """Call after commit to load the event_type_ids of the new EventTypes into the LRU.""" + """Call after commit to load the event_type_ids of the new EventTypes into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ for event_type, db_event_types in self._pending.items(): self._id_map[event_type] = db_event_types.event_type_id self._pending.clear() def reset(self) -> None: - """Reset the event manager after the database has been reset or changed.""" + """Reset the event manager after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ self._id_map.clear() self._pending.clear() def evict_purged(self, event_types: Iterable[str]) -> None: - """Evict purged event_types from the cache when they are no longer used.""" + """Evict purged event_types from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ for event_type in event_types: self._id_map.pop(event_type, None) diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 0352587aa60d..ded1690df134 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -28,10 +28,16 @@ class StatesMetaManager(BaseTableManager): """Initialize the states meta manager.""" self._id_map: dict[str, int] = LRU(CACHE_SIZE) self._pending: dict[str, StatesMeta] = {} + self._did_first_load = False super().__init__(recorder) def load(self, events: list[Event], session: Session) -> None: - """Load the entity_id to metadata_id mapping into memory.""" + """Load the entity_id to metadata_id mapping into memory. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._did_first_load = True self.get_many( { event.data["new_state"].entity_id @@ -39,21 +45,41 @@ class StatesMetaManager(BaseTableManager): if event.data.get("new_state") is not None }, session, + True, ) - def get(self, entity_id: str, session: Session) -> int | None: - """Resolve entity_id to the metadata_id.""" - return self.get_many((entity_id,), session)[entity_id] + def get(self, entity_id: str, session: Session, from_recorder: bool) -> int | None: + """Resolve entity_id to the metadata_id. + + This call is not thread-safe after startup since + purge can remove all references to an entity_id. + + When calling this method from the recorder thread, set + from_recorder to True to ensure any missing entity_ids + are added to the cache. + """ + return self.get_many((entity_id,), session, from_recorder)[entity_id] def get_metadata_id_to_entity_id(self, session: Session) -> dict[int, str]: - """Resolve all entity_ids to metadata_ids.""" + """Resolve all entity_ids to metadata_ids. + + This call is always thread-safe. + """ with session.no_autoflush: return dict(tuple(session.execute(find_all_states_metadata_ids()))) # type: ignore[arg-type] def get_many( - self, entity_ids: Iterable[str], session: Session + self, entity_ids: Iterable[str], session: Session, from_recorder: bool ) -> dict[str, int | None]: - """Resolve entity_id to metadata_id.""" + """Resolve entity_id to metadata_id. + + This call is not thread-safe after startup since + purge can remove all references to an entity_id. + + When calling this method from the recorder thread, set + from_recorder to True to ensure any missing entity_ids + are added to the cache. + """ results: dict[str, int | None] = {} missing: list[str] = [] for entity_id in entity_ids: @@ -65,39 +91,69 @@ class StatesMetaManager(BaseTableManager): if not missing: return results + # Only update the cache if we are in the recorder thread + # or the recorder event loop has not started yet since + # there is a chance that we could have just deleted all + # instances of an entity_id from the database via purge + # and we do not want to add it back to the cache from another + # thread (history query). + update_cache = from_recorder or not self._did_first_load + with session.no_autoflush: for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): for metadata_id, entity_id in session.execute( find_states_metadata_ids(missing_chunk) ): - results[entity_id] = self._id_map[entity_id] = cast( - int, metadata_id - ) + metadata_id = cast(int, metadata_id) + results[entity_id] = metadata_id + + if update_cache: + self._id_map[entity_id] = metadata_id return results def get_pending(self, entity_id: str) -> StatesMeta | None: - """Get pending StatesMeta that have not be assigned ids yet.""" + """Get pending StatesMeta that have not be assigned ids yet. + + This call is not thread-safe and must be called from the + recorder thread. + """ return self._pending.get(entity_id) def add_pending(self, db_states_meta: StatesMeta) -> None: - """Add a pending StatesMeta that will be committed at the next interval.""" + """Add a pending StatesMeta that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ assert db_states_meta.entity_id is not None entity_id: str = db_states_meta.entity_id self._pending[entity_id] = db_states_meta def post_commit_pending(self) -> None: - """Call after commit to load the metadata_ids of the new StatesMeta into the LRU.""" + """Call after commit to load the metadata_ids of the new StatesMeta into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ for entity_id, db_states_meta in self._pending.items(): self._id_map[entity_id] = db_states_meta.metadata_id self._pending.clear() def reset(self) -> None: - """Reset the states meta manager after the database has been reset or changed.""" + """Reset the states meta manager after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ self._id_map.clear() self._pending.clear() def evict_purged(self, entity_ids: Iterable[str]) -> None: - """Evict purged event_types from the cache when they are no longer used.""" + """Evict purged event_types from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ for entity_id in entity_ids: self._id_map.pop(entity_id, None) diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index c9d0be5973fe..e030ef1629d6 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -59,7 +59,7 @@ ORIG_TZ = dt_util.DEFAULT_TIME_ZONE def _get_native_states(hass, entity_id): with session_scope(hass=hass) as session: instance = recorder.get_instance(hass) - metadata_id = instance.states_meta_manager.get(entity_id, session) + metadata_id = instance.states_meta_manager.get(entity_id, session, True) states = [] for dbstate in session.query(States).filter(States.metadata_id == metadata_id): dbstate.entity_id = entity_id diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 6594f0352a51..3411c1eb3085 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -684,7 +684,7 @@ def _convert_pending_states_to_meta(instance: Recorder, session: Session) -> Non states.add(object) entity_id_to_metadata_ids = instance.states_meta_manager.get_many( - entity_ids, session + entity_ids, session, True ) for state in states: @@ -1974,6 +1974,7 @@ async def test_purge_old_states_purges_the_state_metadata_ids( return instance.states_meta_manager.get_many( ["sensor.one", "sensor.two", "sensor.three", "sensor.unused"], session, + True, ) entity_id_to_metadata_id = await instance.async_add_executor_job(_insert_states) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 38622bd45a40..6aad3e440df5 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -908,7 +908,7 @@ def test_execute_stmt_lambda_element( with session_scope(hass=hass) as session: # No time window, we always get a list - metadata_id = instance.states_meta_manager.get("sensor.on", session) + metadata_id = instance.states_meta_manager.get("sensor.on", session, True) stmt = _get_single_entity_states_stmt(dt_util.utcnow(), metadata_id, False) rows = util.execute_stmt_lambda_element(session, stmt) assert isinstance(rows, list) From 6e5b4f9f827ec063e398abf00b8fb56fac9ec7d3 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 15 Mar 2023 14:09:14 +0100 Subject: [PATCH 0491/1058] Add modbus hvac_* write registers (#89695) --- homeassistant/components/modbus/__init__.py | 3 ++ homeassistant/components/modbus/climate.py | 43 +++++++++++++++------ homeassistant/components/modbus/const.py | 1 + 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 043d7375ae32..e8c534697693 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -105,6 +105,7 @@ from .const import ( # noqa: F401 CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, CONF_VERIFY, + CONF_WRITE_REGISTERS, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, DEFAULT_HUB, @@ -232,6 +233,7 @@ CLIMATE_SCHEMA = vol.All( 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, + vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, vol.Optional(CONF_HVAC_MODE_REGISTER): vol.Maybe( { CONF_ADDRESS: cv.positive_int, @@ -244,6 +246,7 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_HVAC_MODE_DRY): cv.positive_int, vol.Optional(CONF_HVAC_MODE_FAN_ONLY): cv.positive_int, }, + vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), } diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 5573ef0b7ec3..0a8b8dabeeb4 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -45,6 +45,7 @@ from .const import ( CONF_MIN_TEMP, CONF_STEP, CONF_TARGET_TEMP, + CONF_WRITE_REGISTERS, DataType, ) from .modbus import ModbusHub @@ -106,6 +107,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_modes = cast(list[HVACMode], []) self._attr_hvac_mode = None self._hvac_mode_mapping: list[tuple[int, HVACMode]] = [] + self._hvac_mode_write_type = mode_config[CONF_WRITE_REGISTERS] mode_value_config = mode_config[CONF_HVAC_MODE_VALUES] for hvac_mode_kw, hvac_mode in ( @@ -131,6 +133,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] + self._hvac_onoff_write_type = config[CONF_WRITE_REGISTERS] if HVACMode.OFF not in self._attr_hvac_modes: self._attr_hvac_modes.append(HVACMode.OFF) else: @@ -147,23 +150,39 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Set new target hvac mode.""" if self._hvac_onoff_register is not None: # Turn HVAC Off by writing 0 to the On/Off register, or 1 otherwise. - await self._hub.async_pymodbus_call( - self._slave, - self._hvac_onoff_register, - 0 if hvac_mode == HVACMode.OFF else 1, - CALL_TYPE_WRITE_REGISTER, - ) + if self._hvac_onoff_write_type: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_onoff_register, + [0 if hvac_mode == HVACMode.OFF else 1], + CALL_TYPE_WRITE_REGISTERS, + ) + else: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_onoff_register, + 0 if hvac_mode == HVACMode.OFF else 1, + CALL_TYPE_WRITE_REGISTER, + ) if self._hvac_mode_register is not None: # Write a value to the mode register for the desired mode. for value, mode in self._hvac_mode_mapping: if mode == hvac_mode: - await self._hub.async_pymodbus_call( - self._slave, - self._hvac_mode_register, - value, - CALL_TYPE_WRITE_REGISTER, - ) + if self._hvac_mode_write_type: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_mode_register, + [value], + CALL_TYPE_WRITE_REGISTERS, + ) + else: + await self._hub.async_pymodbus_call( + self._slave, + self._hvac_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) break await self.async_update() diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index b7fcfee9053c..4191e1df56f4 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -65,6 +65,7 @@ CONF_HVAC_MODE_HEAT_COOL = "state_heat_cool" CONF_HVAC_MODE_AUTO = "state_auto" CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" +CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" From 4d3799a9de1116de8ac202ef970dcbcdba330a97 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Mar 2023 14:22:16 +0100 Subject: [PATCH 0492/1058] Make CalendarEntityFeature an IntFlag (#89733) --- homeassistant/components/calendar/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index aa47cb3592e9..3fbab6742a98 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -1,11 +1,11 @@ """Constants for calendar components.""" -from enum import IntEnum +from enum import IntFlag CONF_EVENT = "event" -class CalendarEntityFeature(IntEnum): +class CalendarEntityFeature(IntFlag): """Supported features of the calendar entity.""" CREATE_EVENT = 1 From 8cbb1e542f5ebbfe7d41e772dbacc9ac8d0d4b5e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 17:14:27 +0100 Subject: [PATCH 0493/1058] Address late feedback for SamsungTV (#89751) --- homeassistant/components/samsungtv/media_player.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index ea31cca3e91d..3fac14a82c81 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -128,8 +128,9 @@ class SamsungTVDevice(MediaPlayerEntity): self._app_list_event: asyncio.Event = asyncio.Event() self._attr_supported_features = SUPPORT_SAMSUNGTV - if self._turn_on or self._on_script or self._mac: - # Add turn-on if turn_on trigger or on_script YAML or mac is available + if self._on_script or self._mac: + # (deprecated) add turn-on if on_script YAML or mac is available + # Triggers have not yet been registered so this is adjusted in the property self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET @@ -157,6 +158,15 @@ class SamsungTVDevice(MediaPlayerEntity): self._dmr_device: DmrDevice | None = None self._upnp_server: AiohttpNotifyServer | None = None + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Flag media player features that are supported.""" + # `turn_on` triggers are not yet registered during initialisation, + # so this property needs to be dynamic + if self._turn_on: + return self._attr_supported_features | MediaPlayerEntityFeature.TURN_ON + return self._attr_supported_features + def _update_sources(self) -> None: self._attr_source_list = list(SOURCES) if app_list := self._app_list: From 6b768b90b4730d4d25d3b5433de190f63df48cfc Mon Sep 17 00:00:00 2001 From: StefanIacobLivisi <109964424+StefanIacobLivisi@users.noreply.github.com> Date: Wed, 15 Mar 2023 19:05:45 +0200 Subject: [PATCH 0494/1058] Bump aiolivisi to 0.0.19 (#89752) --- homeassistant/components/livisi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json index 5b5facd44554..e6f46324ed82 100644 --- a/homeassistant/components/livisi/manifest.json +++ b/homeassistant/components/livisi/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/livisi", "iot_class": "local_polling", - "requirements": ["aiolivisi==0.0.16"] + "requirements": ["aiolivisi==0.0.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index 526713c3a533..63117f0be9f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -202,7 +202,7 @@ aiolifx_effects==0.3.1 aiolifx_themes==0.4.0 # homeassistant.components.livisi -aiolivisi==0.0.16 +aiolivisi==0.0.19 # homeassistant.components.lookin aiolookin==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 409b6d9257b4..65909e2ca800 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -186,7 +186,7 @@ aiolifx_effects==0.3.1 aiolifx_themes==0.4.0 # homeassistant.components.livisi -aiolivisi==0.0.16 +aiolivisi==0.0.19 # homeassistant.components.lookin aiolookin==1.0.0 From b588b8b215b820268fc0db135bd21589203809de Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 15 Mar 2023 10:06:46 -0700 Subject: [PATCH 0495/1058] Bump ical to 4.5.0 (#89744) --- .../components/local_calendar/calendar.py | 31 ++++++++++++------- .../components/local_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../local_calendar/test_calendar.py | 20 ++++++++++++ 5 files changed, 42 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 88737150c02f..9cb6878ca552 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -9,7 +9,7 @@ from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream from ical.event import Event -from ical.store import EventStore +from ical.store import EventStore, EventStoreError from ical.types import Range, Recur from pydantic import ValidationError import voluptuous as vol @@ -24,6 +24,7 @@ from homeassistant.components.calendar import ( ) 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.util import dt as dt_util @@ -119,11 +120,14 @@ class LocalCalendarEntity(CalendarEntity): range_value: Range = Range.NONE if recurrence_range == Range.THIS_AND_FUTURE: range_value = Range.THIS_AND_FUTURE - EventStore(self._calendar).delete( - uid, - recurrence_id=recurrence_id, - recurrence_range=range_value, - ) + try: + EventStore(self._calendar).delete( + uid, + recurrence_id=recurrence_id, + recurrence_range=range_value, + ) + except EventStoreError as err: + raise HomeAssistantError("Error while deleting event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) @@ -139,12 +143,15 @@ class LocalCalendarEntity(CalendarEntity): range_value: Range = Range.NONE if recurrence_range == Range.THIS_AND_FUTURE: range_value = Range.THIS_AND_FUTURE - EventStore(self._calendar).edit( - uid, - new_event, - recurrence_id=recurrence_id, - recurrence_range=range_value, - ) + try: + EventStore(self._calendar).edit( + uid, + new_event, + recurrence_id=recurrence_id, + recurrence_range=range_value, + ) + except EventStoreError as err: + raise HomeAssistantError("Error while updating event: {err}") from err await self._async_store() await self.async_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index a1659a9ba3b9..42cd7fcf5a90 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==4.2.9"] + "requirements": ["ical==4.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63117f0be9f4..a946d316c193 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -952,7 +952,7 @@ ibm-watson==5.2.2 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==4.2.9 +ical==4.5.0 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65909e2ca800..55b9c03c502e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -723,7 +723,7 @@ iaqualink==0.5.0 ibeacon_ble==1.0.1 # homeassistant.components.local_calendar -ical==4.2.9 +ical==4.5.0 # homeassistant.components.ping icmplib==3.0 diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index a859ed1d90c9..319a352f62be 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -829,6 +829,26 @@ async def test_update_invalid_event_id( assert resp["error"].get("code") == "failed" +async def test_delete_invalid_event_id( + ws_client: ClientFixture, + setup_integration: None, + hass: HomeAssistant, +) -> None: + """Test deleting an event with an invalid event uid.""" + client = await ws_client() + resp = await client.cmd( + "delete", + { + "entity_id": TEST_ENTITY, + "uid": "uid-does-not-exist", + }, + ) + assert resp + assert not resp.get("success") + assert "error" in resp + assert resp["error"].get("code") == "failed" + + @pytest.mark.parametrize( ("start_date_time", "end_date_time"), [ From 35c02ddc8107944f889c4cfca7f147f9e06929cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:07:43 +0100 Subject: [PATCH 0496/1058] Add type hints to update coordinator tests (#89748) --- tests/helpers/test_update_coordinator.py | 83 +++++++++++++++++------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 9f904f080200..94a9dfd6b9c4 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -20,10 +20,10 @@ from tests.common import MockConfigEntry, async_fire_time_changed _LOGGER = logging.getLogger(__name__) -KNOWN_ERRORS = [ - (asyncio.TimeoutError, asyncio.TimeoutError, "Timeout fetching test data"), +KNOWN_ERRORS: list[tuple[Exception, type[Exception], str]] = [ + (asyncio.TimeoutError(), asyncio.TimeoutError, "Timeout fetching test data"), ( - requests.exceptions.Timeout, + requests.exceptions.Timeout(), requests.exceptions.Timeout, "Timeout fetching test data", ), @@ -32,9 +32,9 @@ KNOWN_ERRORS = [ urllib.error.URLError, "Timeout fetching test data", ), - (aiohttp.ClientError, aiohttp.ClientError, "Error requesting test data"), + (aiohttp.ClientError(), aiohttp.ClientError, "Error requesting test data"), ( - requests.exceptions.RequestException, + requests.exceptions.RequestException(), requests.exceptions.RequestException, "Error requesting test data", ), @@ -44,14 +44,16 @@ KNOWN_ERRORS = [ "Error requesting test data", ), ( - update_coordinator.UpdateFailed, + update_coordinator.UpdateFailed(), update_coordinator.UpdateFailed, "Error fetching test data", ), ] -def get_crd(hass, update_interval): +def get_crd( + hass: HomeAssistant, update_interval: timedelta | None +) -> update_coordinator.DataUpdateCoordinator[int]: """Make coordinator mocks.""" calls = 0 @@ -74,18 +76,22 @@ DEFAULT_UPDATE_INTERVAL = timedelta(seconds=10) @pytest.fixture -def crd(hass): +def crd(hass: HomeAssistant) -> update_coordinator.DataUpdateCoordinator[int]: """Coordinator mock with default update interval.""" return get_crd(hass, DEFAULT_UPDATE_INTERVAL) @pytest.fixture -def crd_without_update_interval(hass): +def crd_without_update_interval( + hass: HomeAssistant, +) -> update_coordinator.DataUpdateCoordinator[int]: """Coordinator mock that never automatically updates.""" return get_crd(hass, None) -async def test_async_refresh(crd) -> None: +async def test_async_refresh( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test async_refresh for update coordinator.""" assert crd.data is None await crd.async_refresh() @@ -110,7 +116,9 @@ async def test_async_refresh(crd) -> None: assert updates == [2] -async def test_update_context(crd: update_coordinator.DataUpdateCoordinator[int]): +async def test_update_context( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test update contexts for the update coordinator.""" await crd.async_refresh() assert not set(crd.async_contexts()) @@ -134,7 +142,9 @@ async def test_update_context(crd: update_coordinator.DataUpdateCoordinator[int] assert not set(crd.async_contexts()) -async def test_request_refresh(crd) -> None: +async def test_request_refresh( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test request refresh for update coordinator.""" assert crd.data is None await crd.async_request_refresh() @@ -150,7 +160,9 @@ async def test_request_refresh(crd) -> None: crd._unschedule_refresh() -async def test_request_refresh_no_auto_update(crd_without_update_interval) -> None: +async def test_request_refresh_no_auto_update( + crd_without_update_interval: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test request refresh for update coordinator without automatic update.""" crd = crd_without_update_interval assert crd.data is None @@ -172,7 +184,9 @@ async def test_request_refresh_no_auto_update(crd_without_update_interval) -> No KNOWN_ERRORS, ) async def test_refresh_known_errors( - err_msg, crd, caplog: pytest.LogCaptureFixture + err_msg: tuple[Exception, type[Exception], str], + crd: update_coordinator.DataUpdateCoordinator[int], + caplog: pytest.LogCaptureFixture, ) -> None: """Test raising known errors.""" crd.update_method = AsyncMock(side_effect=err_msg[0]) @@ -185,7 +199,9 @@ async def test_refresh_known_errors( assert err_msg[2] in caplog.text -async def test_refresh_fail_unknown(crd, caplog: pytest.LogCaptureFixture) -> None: +async def test_refresh_fail_unknown( + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture +) -> None: """Test raising unknown error.""" await crd.async_refresh() @@ -198,7 +214,9 @@ async def test_refresh_fail_unknown(crd, caplog: pytest.LogCaptureFixture) -> No assert "Unexpected error fetching test data" in caplog.text -async def test_refresh_no_update_method(crd) -> None: +async def test_refresh_no_update_method( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test raising error is no update method is provided.""" await crd.async_refresh() @@ -208,7 +226,9 @@ async def test_refresh_no_update_method(crd) -> None: await crd.async_refresh() -async def test_update_interval(hass: HomeAssistant, crd) -> None: +async def test_update_interval( + hass: HomeAssistant, crd: update_coordinator.DataUpdateCoordinator[int] +) -> None: """Test update interval works.""" # Test we don't update without subscriber async_fire_time_changed(hass, utcnow() + crd.update_interval) @@ -239,7 +259,8 @@ async def test_update_interval(hass: HomeAssistant, crd) -> None: async def test_update_interval_not_present( - hass: HomeAssistant, crd_without_update_interval + hass: HomeAssistant, + crd_without_update_interval: update_coordinator.DataUpdateCoordinator[int], ) -> None: """Test update never happens with no update interval.""" crd = crd_without_update_interval @@ -271,7 +292,9 @@ async def test_update_interval_not_present( assert crd.data is None -async def test_refresh_recover(crd, caplog: pytest.LogCaptureFixture) -> None: +async def test_refresh_recover( + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture +) -> None: """Test recovery of freshing data.""" crd.last_update_success = False @@ -281,7 +304,9 @@ async def test_refresh_recover(crd, caplog: pytest.LogCaptureFixture) -> None: assert "Fetching test data recovered" in caplog.text -async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[int]): +async def test_coordinator_entity( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test the CoordinatorEntity class.""" context = object() entity = update_coordinator.CoordinatorEntity(crd, context) @@ -316,7 +341,9 @@ async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[ assert len(crd._listeners) == 0 -async def test_async_set_updated_data(crd) -> None: +async def test_async_set_updated_data( + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: """Test async_set_updated_data for update coordinator.""" assert crd.data is None @@ -350,7 +377,9 @@ async def test_async_set_updated_data(crd) -> None: assert crd._unsub_refresh is not old_refresh -async def test_stop_refresh_on_ha_stop(hass: HomeAssistant, crd) -> None: +async def test_stop_refresh_on_ha_stop( + hass: HomeAssistant, crd: update_coordinator.DataUpdateCoordinator[int] +) -> None: """Test no update interval refresh when Home Assistant is stopping.""" # Add subscriber update_callback = Mock() @@ -388,7 +417,9 @@ async def test_stop_refresh_on_ha_stop(hass: HomeAssistant, crd) -> None: KNOWN_ERRORS, ) async def test_async_config_entry_first_refresh_failure( - err_msg, crd, caplog: pytest.LogCaptureFixture + err_msg: tuple[Exception, type[Exception], str], + crd: update_coordinator.DataUpdateCoordinator[int], + caplog: pytest.LogCaptureFixture, ) -> None: """Test async_config_entry_first_refresh raises ConfigEntryNotReady on failure. @@ -407,7 +438,7 @@ async def test_async_config_entry_first_refresh_failure( async def test_async_config_entry_first_refresh_success( - crd, caplog: pytest.LogCaptureFixture + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture ) -> None: """Test first refresh successfully.""" await crd.async_config_entry_first_refresh() @@ -426,7 +457,9 @@ async def test_not_schedule_refresh_if_system_option_disable_polling( assert crd._unsub_refresh is None -async def test_async_set_update_error(crd, caplog: pytest.LogCaptureFixture) -> None: +async def test_async_set_update_error( + crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture +) -> None: """Test manually setting an update failure.""" update_callback = Mock() crd.async_add_listener(update_callback) From cb74b934dcde99a149eb85148e9f850990ca6aa4 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:09:39 +0100 Subject: [PATCH 0497/1058] Fix NMBS IndexError (#89698) --- homeassistant/components/nmbs/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index b9a216875f4b..c3bcdb355367 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -162,7 +162,11 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if liveboard is None or not liveboard.get("departures"): + if ( + liveboard is None + or not liveboard.get("departures") + or liveboard.get("number") == "0" + ): return next_departure = liveboard["departures"]["departure"][0] From c416d185061bd5eaabfe1af7348f6845c1c5313b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:49:57 +0100 Subject: [PATCH 0498/1058] Add WAN information to SFR Box (#89678) --- homeassistant/components/sfr_box/__init__.py | 14 +++-- .../components/sfr_box/binary_sensor.py | 22 ++++++-- .../components/sfr_box/diagnostics.py | 6 +- homeassistant/components/sfr_box/models.py | 3 +- homeassistant/components/sfr_box/sensor.py | 24 +++++++- homeassistant/components/sfr_box/strings.json | 9 +++ .../sfr_box/snapshots/test_binary_sensor.ambr | 41 ++++++++++++++ .../sfr_box/snapshots/test_diagnostics.ambr | 2 +- .../sfr_box/snapshots/test_sensor.ambr | 56 +++++++++++++++++++ .../components/sfr_box/test_binary_sensor.py | 2 +- tests/components/sfr_box/test_button.py | 2 +- tests/components/sfr_box/test_init.py | 2 +- tests/components/sfr_box/test_sensor.py | 2 +- 13 files changed, 165 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index 4873acf753e5..b4014c159adc 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -1,11 +1,13 @@ """SFR Box.""" from __future__ import annotations +import asyncio + from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -37,15 +39,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: system=SFRDataUpdateCoordinator( hass, box, "system", lambda b: b.system_get_info() ), + wan=SFRDataUpdateCoordinator(hass, box, "wan", lambda b: b.wan_get_info()), ) + # Preload system information await data.system.async_config_entry_first_refresh() system_info = data.system.data + # Preload other coordinators (based on net infrastructure) + tasks = [data.wan.async_config_entry_first_refresh()] if system_info.net_infra == "adsl": - await data.dsl.async_config_entry_first_refresh() - else: - platforms = list(platforms) - platforms.remove(Platform.BINARY_SENSOR) + tasks.append(data.dsl.async_config_entry_first_refresh()) + await asyncio.gather(*tasks) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 8b3883c6eee8..83c7cd8d1062 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic, TypeVar -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, SystemInfo, WanInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -48,6 +48,15 @@ DSL_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[DslInfo], ...] = ( value_fn=lambda x: x.status == "up", ), ) +WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( + SFRBoxBinarySensorEntityDescription[WanInfo]( + key="status", + name="WAN status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda x: x.status == "up", + ), +) async def async_setup_entry( @@ -56,10 +65,15 @@ async def async_setup_entry( """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] - entities = [ - SFRBoxBinarySensor(data.dsl, description, data.system.data) - for description in DSL_SENSOR_TYPES + entities: list[SFRBoxBinarySensor] = [ + SFRBoxBinarySensor(data.wan, description, data.system.data) + for description in WAN_SENSOR_TYPES ] + if data.system.data.net_infra == "adsl": + entities.extend( + SFRBoxBinarySensor(data.dsl, description, data.system.data) + for description in DSL_SENSOR_TYPES + ) async_add_entities(entities) diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index 60df21739685..e85bd602b71f 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .models import DomainData -TO_REDACT = {"mac_addr", "serial_number"} +TO_REDACT = {"mac_addr", "serial_number", "ip_addr", "ipv6_addr"} async def async_get_config_entry_diagnostics( @@ -33,8 +33,6 @@ async def async_get_config_entry_diagnostics( "system": async_redact_data( dataclasses.asdict(data.system.data), TO_REDACT ), - "wan": async_redact_data( - dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT - ), + "wan": async_redact_data(dataclasses.asdict(data.wan.data), TO_REDACT), }, } diff --git a/homeassistant/components/sfr_box/models.py b/homeassistant/components/sfr_box/models.py index e2f86aeb9241..9302de83e773 100644 --- a/homeassistant/components/sfr_box/models.py +++ b/homeassistant/components/sfr_box/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from sfrbox_api.bridge import SFRBox -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, SystemInfo, WanInfo from .coordinator import SFRDataUpdateCoordinator @@ -14,3 +14,4 @@ class DomainData: box: SFRBox dsl: SFRDataUpdateCoordinator[DslInfo] system: SFRDataUpdateCoordinator[SystemInfo] + wan: SFRDataUpdateCoordinator[WanInfo] diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 48d1c16d0ff0..d276d3082148 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic, TypeVar -from sfrbox_api.models import DslInfo, SystemInfo +from sfrbox_api.models import DslInfo, SystemInfo, WanInfo from homeassistant.components.sensor import ( SensorDeviceClass, @@ -195,6 +195,24 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( value_fn=lambda x: x.temperature / 1000, ), ) +WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( + SFRBoxSensorEntityDescription[WanInfo]( + key="mode", + name="WAN mode", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + options=[ + "adsl_ppp", + "adsl_routed", + "ftth_routed", + "grps_ppp", + "unknown", + ], + translation_key="wan_mode", + value_fn=lambda x: x.mode.replace("/", "_"), + ), +) async def async_setup_entry( @@ -207,6 +225,10 @@ async def async_setup_entry( SFRBoxSensor(data.system, description, data.system.data) for description in SYSTEM_SENSOR_TYPES ] + entities.extend( + SFRBoxSensor(data.wan, description, data.system.data) + for description in WAN_SENSOR_TYPES + ) if data.system.data.net_infra == "adsl": entities.extend( SFRBoxSensor(data.dsl, description, data.system.data) diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index ddff342a10d4..2141abad872c 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -63,6 +63,15 @@ "showtime": "Showtime", "unknown": "Unknown" } + }, + "wan_mode": { + "state": { + "adsl_ppp": "ADSL (PPP)", + "adsl_routed": "ADSL (Routed)", + "ftth_routed": "FTTH (Routed)", + "grps_ppp": "GPRS (PPP)", + "unknown": "Unknown" + } } } } diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index c3442c08e241..a932a3beae32 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -30,6 +30,34 @@ # --- # name: test_binary_sensors.1 list([ + 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.sfr_box_wan_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'WAN status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_wan_status', + 'unit_of_measurement': None, + }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -73,3 +101,16 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[binary_sensor.sfr_box_wan_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box WAN status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_wan_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/sfr_box/snapshots/test_diagnostics.ambr b/tests/components/sfr_box/snapshots/test_diagnostics.ambr index 2e25259268d6..fe9268358f90 100644 --- a/tests/components/sfr_box/snapshots/test_diagnostics.ambr +++ b/tests/components/sfr_box/snapshots/test_diagnostics.ambr @@ -41,7 +41,7 @@ 'wan': dict({ 'infra': 'adsl', 'infra6': '', - 'ip_addr': '1.2.3.4', + 'ip_addr': '**REDACTED**', 'ipv6_addr': '', 'mode': 'adsl/routed', 'status': 'up', diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index a5788a1d6c5e..82e5f3aa15f7 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -119,6 +119,42 @@ 'unique_id': 'e4:5d:51:00:11:22_system_temperature', 'unit_of_measurement': , }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'adsl_ppp', + 'adsl_routed', + 'ftth_routed', + 'grps_ppp', + 'unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.sfr_box_wan_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'WAN mode', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': 'wan_mode', + 'unique_id': 'e4:5d:51:00:11:22_wan_mode', + 'unit_of_measurement': None, + }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -682,3 +718,23 @@ 'state': '12251', }) # --- +# name: test_sensors[sensor.sfr_box_wan_mode] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'SFR Box WAN mode', + 'options': list([ + 'adsl_ppp', + 'adsl_routed', + 'ftth_routed', + 'grps_ppp', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.sfr_box_wan_mode', + 'last_changed': , + 'last_updated': , + 'state': 'adsl_routed', + }) +# --- diff --git a/tests/components/sfr_box/test_binary_sensor.py b/tests/components/sfr_box/test_binary_sensor.py index 03e0677713b5..c7a643a91687 100644 --- a/tests/components/sfr_box/test_binary_sensor.py +++ b/tests/components/sfr_box/test_binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") +pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") @pytest.fixture(autouse=True) diff --git a/tests/components/sfr_box/test_button.py b/tests/components/sfr_box/test_button.py index d1bb06fc79c7..202593e4825e 100644 --- a/tests/components/sfr_box/test_button.py +++ b/tests/components/sfr_box/test_button.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") +pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") @pytest.fixture(autouse=True) diff --git a/tests/components/sfr_box/test_init.py b/tests/components/sfr_box/test_init.py index 3a740753b21f..df4d1242e02b 100644 --- a/tests/components/sfr_box/test_init.py +++ b/tests/components/sfr_box/test_init.py @@ -17,7 +17,7 @@ def override_platforms() -> Generator[None, None, None]: yield -@pytest.mark.usefixtures("system_get_info", "dsl_get_info") +@pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") async def test_setup_unload_entry( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: diff --git a/tests/components/sfr_box/test_sensor.py b/tests/components/sfr_box/test_sensor.py index 4e2c9e33a748..ff84defb8325 100644 --- a/tests/components/sfr_box/test_sensor.py +++ b/tests/components/sfr_box/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info") +pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") @pytest.fixture(autouse=True) From 54ad8b8ee91b6c33d8ff0d99153dd95751287a6f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:50:32 +0100 Subject: [PATCH 0499/1058] Avoid lingering timers in update coordinator tests (#89749) --- tests/helpers/test_update_coordinator.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 94a9dfd6b9c4..fc6b7bcf7576 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -365,7 +365,7 @@ async def test_async_set_updated_data( def update_callback(): updates.append(crd.data) - crd.async_add_listener(update_callback) + remove_callbacks = crd.async_add_listener(update_callback) crd.async_set_updated_data(200) assert updates == [200] assert crd._unsub_refresh is not None @@ -376,6 +376,9 @@ async def test_async_set_updated_data( # We have created a new refresh listener assert crd._unsub_refresh is not old_refresh + # Remove callbacks to avoid lingering timers + remove_callbacks() + async def test_stop_refresh_on_ha_stop( hass: HomeAssistant, crd: update_coordinator.DataUpdateCoordinator[int] @@ -462,7 +465,7 @@ async def test_async_set_update_error( ) -> None: """Test manually setting an update failure.""" update_callback = Mock() - crd.async_add_listener(update_callback) + remove_callbacks = crd.async_add_listener(update_callback) crd.async_set_update_error(aiohttp.ClientError("Client Failure #1")) assert crd.last_update_success is False @@ -486,3 +489,6 @@ async def test_async_set_update_error( assert crd.last_update_success is False assert "Client Failure #2" not in caplog.text update_callback.assert_called_once() + + # Remove callbacks to avoid lingering timers + remove_callbacks() From dea29f539f317a97e6a5f2f16131fb64884dc801 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 15 Mar 2023 18:52:42 +0100 Subject: [PATCH 0500/1058] Use `SensorDeviceClass.ENUM` and add state attributes translations in Shelly integration (#89660) --- .../components/shelly/binary_sensor.py | 1 + homeassistant/components/shelly/sensor.py | 3 ++ homeassistant/components/shelly/strings.json | 37 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 820afcf0f099..449fc1422182 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -91,6 +91,7 @@ SENSORS: Final = { key="sensor|gas", name="Gas", device_class=BinarySensorDeviceClass.GAS, + translation_key="gas", value=lambda value: value in ["mild", "heavy"], extra_state_attributes=lambda block: {"detected": block.gas}, ), diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 5e05134fdc32..b83131a1004c 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -295,6 +295,9 @@ SENSORS: Final = { ("sensor", "sensorOp"): BlockSensorDescription( key="sensor|sensorOp", name="Operation", + device_class=SensorDeviceClass.ENUM, + options=["unknown", "warmup", "normal", "fault"], + translation_key="operation", icon="mdi:cog-transfer", value=lambda value: value, extra_state_attributes=lambda block: {"self_test": block.selfTest}, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 62a43c5bbc0d..f0d4e1c9bd95 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -81,5 +81,42 @@ "passive": "Passive" } } + }, + "entity": { + "binary_sensor": { + "gas": { + "state_attributes": { + "detected": { + "state": { + "unknown": "Unknown", + "none": "None", + "mild": "Mild", + "heavy": "Heavy", + "test": "Test" + } + } + } + } + }, + "sensor": { + "operation": { + "state": { + "unknown": "Unknown", + "warmup": "Warm-up", + "normal": "normal", + "fault": "Fault" + }, + "state_attributes": { + "self_test": { + "state": { + "not_completed": "Not completed", + "completed": "Completed", + "running": "Running", + "pending": "Pending" + } + } + } + } + } } } From 4eee6267703c9287c893a58c2a2354cb56fc17a4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 15 Mar 2023 18:54:28 +0100 Subject: [PATCH 0501/1058] Reolink check firmware (#88903) --- homeassistant/components/reolink/host.py | 20 +++++++++++++++++++ homeassistant/components/reolink/strings.json | 4 ++++ tests/components/reolink/test_init.py | 13 ++++++++++++ 3 files changed, 37 insertions(+) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 9d54191aadda..e8e96ffe9b99 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -138,6 +138,26 @@ class ReolinkHost: await self.subscribe() + if self._api.sw_version_update_required: + ir.async_create_issue( + self._hass, + DOMAIN, + "firmware_update", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="firmware_update", + translation_placeholders={ + "required_firmware": self._api.sw_version_required.version_string, + "current_firmware": self._api.sw_version, + "model": self._api.model, + "hw_version": self._api.hardware_version, + "name": self._api.nvr_name, + "download_link": "https://reolink.com/download-center/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "firmware_update") + async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" await self._api.get_states() diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5047c4f2713e..06b588a119c7 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -46,6 +46,10 @@ "enable_port": { "title": "Reolink port not enabled", "description": "Failed to automatically enable {ports}port(s) on {name}. Use the [Reolink client]({info_link}) to manually set it to ON" + }, + "firmware_update": { + "title": "Reolink firmware update required", + "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The latest firmware can be downloaded from the [Reolink download center]({download_link})." } }, "entity": { diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 52bd2d8c5f85..8849c7d52d3f 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -100,6 +100,7 @@ async def test_no_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") not in issue_registry.issues assert (const.DOMAIN, "enable_port") not in issue_registry.issues + assert (const.DOMAIN, "firmware_update") not in issue_registry.issues async def test_https_repair_issue( @@ -135,3 +136,15 @@ async def test_port_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "enable_port") in issue_registry.issues + + +async def test_firmware_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock +) -> None: + """Test firmware issue is raised when too old firmware is used.""" + reolink_connect.sw_version_update_required = True + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "firmware_update") in issue_registry.issues From fceb20838163412e2842388b1ca3b84e8044f99d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 15 Mar 2023 18:55:34 +0100 Subject: [PATCH 0502/1058] Abort Hue config flow if bridge can not be reached (#88893) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Franck Nijhof --- homeassistant/components/hue/config_flow.py | 27 ++++++-- tests/components/hue/test_config_flow.py | 75 ++++++++++++++++++++- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index cd2553353b3e..2b0ebdebcaa4 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -78,7 +78,13 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): bridge = await discover_bridge( host, websession=aiohttp_client.async_get_clientsession(self.hass) ) - except aiohttp.ClientError: + except aiohttp.ClientError as err: + LOGGER.warning( + "Error while attempting to retrieve discovery information, " + "is there a bridge alive on IP %s ?", + host, + exc_info=err, + ) return None if bridge_id is not None: bridge_id = normalize_bridge_id(bridge_id) @@ -147,7 +153,9 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._async_abort_entries_match({"host": user_input["host"]}) - self.bridge = await self._get_bridge(user_input[CONF_HOST]) + if (bridge := await self._get_bridge(user_input[CONF_HOST])) is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge return await self.async_step_link() async def async_step_link( @@ -224,9 +232,12 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) # we need to query the other capabilities too - self.bridge = await self._get_bridge( + bridge = await self._get_bridge( discovery_info.host, discovery_info.properties["bridgeid"] ) + if bridge is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge return await self.async_step_link() async def async_step_homekit( @@ -238,7 +249,10 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): as the unique identifier. Therefore, this method uses discovery without a unique ID. """ - self.bridge = await self._get_bridge(discovery_info.host) + bridge = await self._get_bridge(discovery_info.host) + if bridge is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge await self._async_handle_discovery_without_unique_id() return await self.async_step_link() @@ -254,7 +268,10 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check if host exists, abort if so. self._async_abort_entries_match({"host": import_info["host"]}) - self.bridge = await self._get_bridge(import_info["host"]) + bridge = await self._get_bridge(import_info["host"]) + if bridge is None: + return self.async_abort(reason="cannot_connect") + self.bridge = bridge return await self.async_step_link() diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 7f6e4ae1d017..6fa03e1de139 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.test_util.aiohttp import AiohttpClientMocker, ClientError @pytest.fixture(name="hue_setup", autouse=True) @@ -645,3 +645,76 @@ async def test_bridge_zeroconf_ipv6(hass: HomeAssistant) -> None: assert result["type"] == "abort" assert result["reason"] == "invalid_host" + + +async def test_bridge_connection_failed( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that connection errors to the bridge are handled.""" + create_mock_api_discovery(aioclient_mock, []) + + with patch( + "homeassistant.components.hue.config_flow.discover_bridge", + side_effect=ClientError, + ): + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"host": "blah"} + ) + + # a warning message should have been logged that the bridge could not be reached + assert "Error while attempting to retrieve discovery information" in caplog.text + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + # test again with zeroconf discovered wrong bridge IP + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="blah", + addresses=["1.2.3.4"], + port=443, + hostname="Philips-hue.local", + type="_hue._tcp.local.", + name="Philips Hue - ABCABC._hue._tcp.local.", + properties={ + "_raw": {"bridgeid": b"ecb5fafffeabcabc", "modelid": b"BSB002"}, + "bridgeid": "ecb5fafffeabcabc", + "modelid": "BSB002", + }, + ), + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + # test again with homekit discovered wrong bridge IP + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=zeroconf.ZeroconfServiceInfo( + host="0.0.0.0", + addresses=["0.0.0.0"], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + # repeat test with import flow + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={"host": "blah"}, + ) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" From b7ac0058af7c99fb4da9cc153d386d7fd6c61051 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Mar 2023 18:56:58 +0100 Subject: [PATCH 0503/1058] Fix hassio cleanup when addon in uninstalled (#89756) --- homeassistant/components/hassio/discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 6d936c6ce2f1..29cb53de70f4 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -130,4 +130,4 @@ class HassIODiscovery(HomeAssistantView): for entry in self.hass.config_entries.async_entries(service): if entry.source != config_entries.SOURCE_HASSIO: continue - await self.hass.config_entries.async_remove(entry) + await self.hass.config_entries.async_remove(entry.entry_id) From d4edec28633afa6efb5b43debef87b12776ec5c2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:59:03 +0100 Subject: [PATCH 0504/1058] Move calculation of current value into lib in Fritz!SmartHome (#89150) --- homeassistant/components/fritzbox/sensor.py | 13 +------------ tests/components/fritzbox/__init__.py | 1 + tests/components/fritzbox/test_switch.py | 18 ------------------ 3 files changed, 2 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 4d045c2c98d7..a048a7bba540 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -74,17 +74,6 @@ def suitable_temperature(device: FritzhomeDevice) -> bool: return device.has_temperature_sensor and not device.has_thermostat -def value_electric_current(device: FritzhomeDevice) -> float: - """Return native value for electric current sensor.""" - if ( - isinstance(device.power, int) - and isinstance(device.voltage, int) - and device.voltage > 0 - ): - return round(device.power / device.voltage, 3) - return 0.0 - - def value_nextchange_preset(device: FritzhomeDevice) -> str: """Return native value for next scheduled preset sensor.""" if device.nextchange_temperature == device.eco_temperature: @@ -153,7 +142,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] - native_value=value_electric_current, + native_value=lambda device: round((device.current or 0.0) / 1000, 3), ), FritzSensorEntityDescription( key="total_energy", diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 6cf60a906567..15ff04f37207 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -129,6 +129,7 @@ class FritzDeviceSwitchMock(FritzEntityBaseMock): device_lock = "fake_locked_device" energy = 1234 voltage = 230000 + current = 25 fw_version = "1.2.3" has_alarm = False has_powermeter = True diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index 8555cdb9895b..fdc3cbff2a55 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -174,21 +174,3 @@ async def test_assume_device_unavailable(hass: HomeAssistant, fritz: Mock) -> No state = hass.states.get(ENTITY_ID) assert state assert state.state == STATE_UNAVAILABLE - - -async def test_device_current_unavailable(hass: HomeAssistant, fritz: Mock) -> None: - """Test current in case voltage and power are not available.""" - device = FritzDeviceSwitchMock() - device.voltage = None - device.power = None - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - state = hass.states.get(ENTITY_ID) - assert state - assert state.state == STATE_ON - - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_electric_current") - assert state - assert state.state == "0.0" From bf21b2622c407cbe008b6c52782e88e1cc09febf Mon Sep 17 00:00:00 2001 From: zhangshengdong29 <435878393@qq.com> Date: Thu, 16 Mar 2023 02:13:32 +0800 Subject: [PATCH 0505/1058] ArestData does not have available (#88631) --- homeassistant/components/arest/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 5c95fd63c3b3..2e6012e0e6bb 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -180,7 +180,7 @@ class ArestData: self._resource = resource self._pin = pin self.data = {} - self._attr_available = True + self.available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -201,7 +201,7 @@ class ArestData: f"{self._resource}/digital/{self._pin}", timeout=10 ) self.data = {"value": response.json()["return_value"]} - self._attr_available = True + self.available = True except requests.exceptions.ConnectionError: _LOGGER.error("No route to device %s", self._resource) - self._attr_available = False + self.available = False From 3aa5629665ddeea1b9f9948967beb82e5dba971d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Mar 2023 19:42:23 +0100 Subject: [PATCH 0506/1058] Improve type hints in condition helper tests (#89754) --- tests/helpers/test_condition.py | 37 ++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index bb4f86271154..e345da1538c7 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,5 +1,6 @@ """Test the condition helper.""" from datetime import datetime, timedelta +from typing import Any from unittest.mock import AsyncMock, patch import pytest @@ -16,7 +17,7 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConditionError, HomeAssistantError from homeassistant.helpers import ( condition, @@ -33,13 +34,13 @@ from tests.typing import WebSocketGenerator @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @pytest.fixture(autouse=True) -def setup_comp(hass): +def setup_comp(hass: HomeAssistant) -> None: """Initialize components.""" hass.config.set_time_zone(hass.config.time_zone) hass.loop.run_until_complete( @@ -69,7 +70,7 @@ def assert_element(trace_element, expected_element, path): @pytest.fixture(autouse=True) -def prepare_condition_trace(): +def prepare_condition_trace() -> None: """Clear previous trace.""" trace.trace_clear() @@ -1177,7 +1178,9 @@ async def test_state_for_template(hass: HomeAssistant) -> None: @pytest.mark.parametrize("for_template", [{"{{invalid}}": 5}, {"hours": "{{ 1/0 }}"}]) -async def test_state_for_invalid_template(hass: HomeAssistant, for_template) -> None: +async def test_state_for_invalid_template( + hass: HomeAssistant, for_template: dict[str, Any] +) -> None: """Test state with invalid templated duration.""" config = { "condition": "and", @@ -2217,7 +2220,7 @@ async def assert_automation_condition_trace(hass_ws_client, automation_id, expec async def test_if_action_before_sunrise_no_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise. @@ -2288,7 +2291,7 @@ async def test_if_action_before_sunrise_no_offset( async def test_if_action_after_sunrise_no_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise. @@ -2359,7 +2362,7 @@ async def test_if_action_after_sunrise_no_offset( async def test_if_action_before_sunrise_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise with offset. @@ -2482,7 +2485,7 @@ async def test_if_action_before_sunrise_with_offset( async def test_if_action_before_sunset_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunset with offset. @@ -2605,7 +2608,7 @@ async def test_if_action_before_sunset_with_offset( async def test_if_action_after_sunrise_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise with offset. @@ -2752,7 +2755,7 @@ async def test_if_action_after_sunrise_with_offset( async def test_if_action_after_sunset_with_offset( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunset with offset. @@ -2827,7 +2830,7 @@ async def test_if_action_after_sunset_with_offset( async def test_if_action_after_and_before_during( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise and before sunset. @@ -2930,7 +2933,7 @@ async def test_if_action_after_and_before_during( async def test_if_action_before_or_after_during( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise or after sunset. @@ -3053,7 +3056,7 @@ async def test_if_action_before_or_after_during( async def test_if_action_before_sunrise_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise. @@ -3130,7 +3133,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( async def test_if_action_after_sunrise_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise. @@ -3207,7 +3210,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( async def test_if_action_before_sunset_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was before sunrise. @@ -3284,7 +3287,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( async def test_if_action_after_sunset_no_offset_kotzebue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, calls: list[ServiceCall] ) -> None: """Test if action was after sunrise. From b43b2eb3cb67876d9015cad5913fa93df7bba53e Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Thu, 16 Mar 2023 08:40:22 +1300 Subject: [PATCH 0507/1058] Avoid rounding Starlink sensor data & instead allow configurable precision (#89486) --- homeassistant/components/starlink/sensor.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index bb84f0322429..79cd5ca38958 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -66,7 +66,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( icon="mdi:speedometer", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MILLISECONDS, - value_fn=lambda data: round(data.status["pop_ping_latency_ms"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["pop_ping_latency_ms"], ), StarlinkSensorEntityDescription( key="azimuth", @@ -76,7 +77,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, entity_registry_enabled_default=False, - value_fn=lambda data: round(data.status["direction_azimuth"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["direction_azimuth"], ), StarlinkSensorEntityDescription( key="elevation", @@ -86,7 +88,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=DEGREE, entity_registry_enabled_default=False, - value_fn=lambda data: round(data.status["direction_elevation"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["direction_elevation"], ), StarlinkSensorEntityDescription( key="uplink_throughput", @@ -95,7 +98,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, - value_fn=lambda data: round(data.status["uplink_throughput_bps"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["uplink_throughput_bps"], ), StarlinkSensorEntityDescription( key="downlink_throughput", @@ -104,7 +108,8 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, - value_fn=lambda data: round(data.status["downlink_throughput_bps"]), + suggested_display_precision=0, + value_fn=lambda data: data.status["downlink_throughput_bps"], ), StarlinkSensorEntityDescription( key="last_boot_time", From 7a267460d3f771effd2fb3689a2ea9b704cd107e Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 15 Mar 2023 15:42:23 -0400 Subject: [PATCH 0508/1058] Cache remote app list for vizio TVs (#89003) --- homeassistant/components/vizio/__init__.py | 63 +++++++++++-------- .../components/vizio/media_player.py | 16 +++-- 2 files changed, 47 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 9fc40c40c2bc..d694f4b93f88 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -66,8 +67,9 @@ 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 ): - coordinator = VizioAppsDataUpdateCoordinator(hass) - await coordinator.async_refresh() + store: Store = Store(hass, 1, DOMAIN) + coordinator = VizioAppsDataUpdateCoordinator(hass, store) + await coordinator.async_config_entry_first_refresh() hass.data[DOMAIN][CONF_APPS] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -98,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) -> None: + def __init__(self, hass: HomeAssistant, store: Store) -> None: """Initialize.""" super().__init__( hass, @@ -107,31 +109,40 @@ class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]] update_interval=timedelta(days=1), update_method=self._async_update_data, ) - self.data = APPS self.fail_count = 0 self.fail_threshold = 10 + self.store = store + + async def async_config_entry_first_refresh(self) -> None: + """Refresh data for the first time when a config entry is setup.""" + self.data = await self.store.async_load() or APPS + await super().async_config_entry_first_refresh() async def _async_update_data(self) -> list[dict[str, Any]]: """Update data via library.""" - data = await gen_apps_list_from_url(session=async_get_clientsession(self.hass)) - if not data: - # For every failure, increase the fail count until we reach the threshold. - # We then log a warning, increase the threshold, and reset the fail count. - # This is here to prevent silent failures but to reduce repeat logs. - if self.fail_count == self.fail_threshold: - _LOGGER.warning( - ( - "Unable to retrieve the apps list from the external server " - "for the last %s days" - ), - self.fail_threshold, - ) - self.fail_count = 0 - self.fail_threshold += 10 - else: - self.fail_count += 1 - return self.data - # Reset the fail count and threshold when the data is successfully retrieved - self.fail_count = 0 - self.fail_threshold = 10 - return sorted(data, key=lambda app: app["name"]) + if data := await gen_apps_list_from_url( + session=async_get_clientsession(self.hass) + ): + # Reset the fail count and threshold when the data is successfully retrieved + self.fail_count = 0 + self.fail_threshold = 10 + # Store the new data if it has changed so we have it for the next restart + if data != self.data: + await self.store.async_save(data) + return data + # For every failure, increase the fail count until we reach the threshold. + # We then log a warning, increase the threshold, and reset the fail count. + # This is here to prevent silent failures but to reduce repeat logs. + if self.fail_count == self.fail_threshold: + _LOGGER.warning( + ( + "Unable to retrieve the apps list from the external server for the " + "last %s days" + ), + self.fail_threshold, + ) + self.fail_count = 0 + self.fail_threshold += 10 + else: + self.fail_count += 1 + return self.data diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index e1ca306ddf7a..a989cea488f8 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -137,7 +137,7 @@ class VizioDevice(MediaPlayerEntity): device: VizioAsync, name: str, device_class: MediaPlayerDeviceClass, - apps_coordinator: VizioAppsDataUpdateCoordinator, + apps_coordinator: VizioAppsDataUpdateCoordinator | None, ) -> None: """Initialize Vizio device.""" self._config_entry = config_entry @@ -330,17 +330,21 @@ class VizioDevice(MediaPlayerEntity): ) ) + if not self._apps_coordinator: + return + # Register callback for app list updates if device is a TV @callback - def apps_list_update(): + def apps_list_update() -> None: """Update list of all apps.""" + if not self._apps_coordinator: + return self._all_apps = self._apps_coordinator.data self.async_write_ha_state() - if self._attr_device_class == MediaPlayerDeviceClass.TV: - self.async_on_remove( - self._apps_coordinator.async_add_listener(apps_list_update) - ) + self.async_on_remove( + self._apps_coordinator.async_add_listener(apps_list_update) + ) @property def source(self) -> str | None: From 6ba5f8e43af2cb40c7ce6208c4809dfb1943ec76 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 15 Mar 2023 21:22:13 +0100 Subject: [PATCH 0509/1058] Fix imap server push holding HA startup (#89750) --- homeassistant/components/imap/coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 8a716fe47863..e170f79e7f49 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -77,7 +77,9 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) if self.support_push: - self.hass.async_create_task(self.async_wait_server_push()) + self.hass.async_create_background_task( + self.async_wait_server_push(), "Wait for IMAP data push" + ) return len(lines[0].split()) async def async_wait_server_push(self) -> None: @@ -100,5 +102,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): async def shutdown(self, *_) -> None: """Close resources.""" if self.imap_client: + if self.imap_client.has_pending_idle(): + self.imap_client.idle_done() await self.imap_client.stop_wait_server_push() await self.imap_client.logout() From aec2d6330247ff089ccae0eea9ec7730decbb949 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 11:13:47 -1000 Subject: [PATCH 0510/1058] Add keep_days to recorder.purge_entities (#89726) --- homeassistant/components/recorder/services.py | 26 +++++-- .../components/recorder/services.yaml | 10 +++ tests/components/recorder/test_purge.py | 69 +++++++++++++++++++ 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index e1b2e388d6c9..fb2cd1f0befb 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -9,7 +9,10 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import generate_filter -from homeassistant.helpers.service import async_extract_entity_ids +from homeassistant.helpers.service import ( + async_extract_entity_ids, + async_register_admin_service, +) import homeassistant.util.dt as dt_util from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN @@ -38,6 +41,7 @@ SERVICE_PURGE_ENTITIES_SCHEMA = vol.Schema( vol.Optional(ATTR_ENTITY_GLOBS, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(ATTR_KEEP_DAYS, default=0): cv.positive_int, } ).extend(cv.ENTITY_SERVICE_FIELDS) @@ -56,8 +60,12 @@ def _async_register_purge_service(hass: HomeAssistant, instance: Recorder) -> No purge_before = dt_util.utcnow() - timedelta(days=keep_days) instance.queue_task(PurgeTask(purge_before, repack, apply_filter)) - hass.services.async_register( - DOMAIN, SERVICE_PURGE, async_handle_purge_service, schema=SERVICE_PURGE_SCHEMA + async_register_admin_service( + hass, + DOMAIN, + SERVICE_PURGE, + async_handle_purge_service, + schema=SERVICE_PURGE_SCHEMA, ) @@ -69,12 +77,14 @@ def _async_register_purge_entities_service( """Handle calls to the purge entities service.""" entity_ids = await async_extract_entity_ids(hass, service) domains = service.data.get(ATTR_DOMAINS, []) + keep_days = service.data.get(ATTR_KEEP_DAYS, 0) entity_globs = service.data.get(ATTR_ENTITY_GLOBS, []) entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs) - purge_before = dt_util.utcnow() + purge_before = dt_util.utcnow() - timedelta(days=keep_days) instance.queue_task(PurgeEntitiesTask(entity_filter, purge_before)) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_PURGE_ENTITIES, async_handle_purge_entities_service, @@ -87,7 +97,8 @@ def _async_register_enable_service(hass: HomeAssistant, instance: Recorder) -> N async def async_handle_enable_service(service: ServiceCall) -> None: instance.set_enable(True) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_ENABLE, async_handle_enable_service, @@ -100,7 +111,8 @@ def _async_register_disable_service(hass: HomeAssistant, instance: Recorder) -> async def async_handle_disable_service(service: ServiceCall) -> None: instance.set_enable(False) - hass.services.async_register( + async_register_admin_service( + hass, DOMAIN, SERVICE_DISABLE, async_handle_disable_service, diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index 43ff7548dd6d..f099cede9f21 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -51,6 +51,16 @@ purge_entities: selector: object: + keep_days: + name: Days to keep + description: Number of history days to keep in database of matching rows. The default of 0 days will remove all matching rows. + default: 0 + selector: + number: + min: 0 + max: 365 + unit_of_measurement: days + disable: name: Disable description: Stop the recording of events and state changes diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 3411c1eb3085..2979b04e5c49 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -4,6 +4,7 @@ import json import sqlite3 from unittest.mock import patch +from freezegun import freeze_time import pytest from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session @@ -25,6 +26,7 @@ from homeassistant.components.recorder.db_schema import ( StatisticsRuns, StatisticsShortTerm, ) +from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.recorder.purge import purge_old_data from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.services import ( @@ -2021,3 +2023,70 @@ async def test_purge_old_states_purges_the_state_metadata_ids( assert finished assert states.count() == 0 assert states_meta.count() == 0 + + +async def test_purge_entities_keep_days( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test purging states with an entity filter and keep_days.""" + instance = await async_setup_recorder_instance(hass, {}) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + start = dt_util.utcnow() + two_days_ago = start - timedelta(days=2) + one_week_ago = start - timedelta(days=7) + one_month_ago = start - timedelta(days=30) + with freeze_time(one_week_ago): + hass.states.async_set("sensor.keep", "initial") + hass.states.async_set("sensor.purge", "initial") + + await async_wait_recording_done(hass) + + with freeze_time(two_days_ago): + hass.states.async_set("sensor.purge", "two_days_ago") + + await async_wait_recording_done(hass) + + hass.states.async_set("sensor.purge", "now") + hass.states.async_set("sensor.keep", "now") + await async_recorder_block_till_done(hass) + + states = await instance.async_add_executor_job( + get_significant_states, hass, one_month_ago + ) + assert len(states["sensor.keep"]) == 2 + assert len(states["sensor.purge"]) == 3 + + await hass.services.async_call( + recorder.DOMAIN, + SERVICE_PURGE_ENTITIES, + { + "entity_id": "sensor.purge", + "keep_days": 1, + }, + ) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + states = await instance.async_add_executor_job( + get_significant_states, hass, one_month_ago + ) + assert len(states["sensor.keep"]) == 2 + assert len(states["sensor.purge"]) == 1 + + await hass.services.async_call( + recorder.DOMAIN, + SERVICE_PURGE_ENTITIES, + { + "entity_id": "sensor.purge", + }, + ) + await async_recorder_block_till_done(hass) + await async_wait_purge_done(hass) + + states = await instance.async_add_executor_job( + get_significant_states, hass, one_month_ago + ) + assert len(states["sensor.keep"]) == 2 + assert "sensor.purge" not in states From 69078b5aed2a8dbcb0e72e5ae53176cd3f498091 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 12:14:49 -1000 Subject: [PATCH 0511/1058] Bump pyblackbird to 0.6 for py3.11 (#89719) --- homeassistant/components/blackbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json index fd7cc76aada9..d75b69dfaf89 100644 --- a/homeassistant/components/blackbird/manifest.json +++ b/homeassistant/components/blackbird/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/blackbird", "iot_class": "local_polling", "loggers": ["pyblackbird"], - "requirements": ["pyblackbird==0.5"] + "requirements": ["pyblackbird==0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index a946d316c193..a718ea28974a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1522,7 +1522,7 @@ pybalboa==1.0.1 pybbox==0.0.5-alpha # homeassistant.components.blackbird -pyblackbird==0.5 +pyblackbird==0.6 # homeassistant.components.bluetooth_tracker # pybluez==0.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55b9c03c502e..a35b960b2b6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1113,7 +1113,7 @@ pyaussiebb==0.0.15 pybalboa==1.0.1 # homeassistant.components.blackbird -pyblackbird==0.5 +pyblackbird==0.6 # homeassistant.components.neato pybotvac==0.0.23 From a360da8bc34e3656ed64817e0b75dda8c69dbecd Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 15 Mar 2023 11:28:43 -1100 Subject: [PATCH 0512/1058] Update xknx to 2.7.0 (#89765) --- 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 ce09032e1af2..0ad4404290a7 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["xknx"], "quality_scale": "platinum", - "requirements": ["xknx==2.6.0"] + "requirements": ["xknx==2.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a718ea28974a..6b8b2f978062 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2653,7 +2653,7 @@ xboxapi==2.0.1 xiaomi-ble==0.16.4 # homeassistant.components.knx -xknx==2.6.0 +xknx==2.7.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a35b960b2b6c..0591758b9f69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1890,7 +1890,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.16.4 # homeassistant.components.knx -xknx==2.6.0 +xknx==2.7.0 # homeassistant.components.bluesound # homeassistant.components.fritz From ccab45520b9c10bd3033988a5f07e28b242b6c5a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 14:04:31 -1000 Subject: [PATCH 0513/1058] Remove asyncio.coroutine workarounds (#88560) --- tests/asyncio_legacy.py | 128 ---------------------------------------- tests/conftest.py | 6 -- 2 files changed, 134 deletions(-) delete mode 100644 tests/asyncio_legacy.py diff --git a/tests/asyncio_legacy.py b/tests/asyncio_legacy.py deleted file mode 100644 index bc054b7e36e7..000000000000 --- a/tests/asyncio_legacy.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Minimal legacy asyncio.coroutine.""" - -# flake8: noqa -# stubbing out for integrations that have -# not yet been updated for python 3.11 -# but can still run on python 3.10 -# -# Remove this once rflink, fido, and blackbird -# have had their libraries updated to remove -# asyncio.coroutine -from asyncio import base_futures, constants, format_helpers -from asyncio.coroutines import _is_coroutine -import collections.abc -import functools -import inspect -import logging -import traceback -import types -import warnings - -logger = logging.getLogger(__name__) - - -class CoroWrapper: - # Wrapper for coroutine object in _DEBUG mode. - - def __init__(self, gen, func=None): - assert inspect.isgenerator(gen) or inspect.iscoroutine(gen), gen - self.gen = gen - self.func = func # Used to unwrap @coroutine decorator - self._source_traceback = format_helpers.extract_stack(sys._getframe(1)) - self.__name__ = getattr(gen, "__name__", None) - self.__qualname__ = getattr(gen, "__qualname__", None) - - def __iter__(self): - return self - - def __next__(self): - return self.gen.send(None) - - def send(self, value): - return self.gen.send(value) - - def throw(self, type, value=None, traceback=None): - return self.gen.throw(type, value, traceback) - - def close(self): - return self.gen.close() - - @property - def gi_frame(self): - return self.gen.gi_frame - - @property - def gi_running(self): - return self.gen.gi_running - - @property - def gi_code(self): - return self.gen.gi_code - - def __await__(self): - return self - - @property - def gi_yieldfrom(self): - return self.gen.gi_yieldfrom - - def __del__(self): - # Be careful accessing self.gen.frame -- self.gen might not exist. - gen = getattr(self, "gen", None) - frame = getattr(gen, "gi_frame", None) - if frame is not None and frame.f_lasti == -1: - msg = f"{self!r} was never yielded from" - tb = getattr(self, "_source_traceback", ()) - if tb: - tb = "".join(traceback.format_list(tb)) - msg += ( - f"\nCoroutine object created at " - f"(most recent call last, truncated to " - f"{constants.DEBUG_STACK_DEPTH} last lines):\n" - ) - msg += tb.rstrip() - logger.error(msg) - - -def legacy_coroutine(func): - """Decorator to mark coroutines. - If the coroutine is not yielded from before it is destroyed, - an error message is logged. - """ - warnings.warn( - '"@coroutine" decorator is deprecated since Python 3.8, use "async def" instead', - DeprecationWarning, - stacklevel=2, - ) - if inspect.iscoroutinefunction(func): - # In Python 3.5 that's all we need to do for coroutines - # defined with "async def". - return func - - if inspect.isgeneratorfunction(func): - coro = func - else: - - @functools.wraps(func) - def coro(*args, **kw): - res = func(*args, **kw) - if ( - base_futures.isfuture(res) - or inspect.isgenerator(res) - or isinstance(res, CoroWrapper) - ): - res = yield from res - else: - # If 'res' is an awaitable, run it. - try: - await_meth = res.__await__ - except AttributeError: - pass - else: - if isinstance(res, collections.abc.Awaitable): - res = yield from await_meth() - return res - - wrapper = types.coroutine(coro) - wrapper._is_coroutine = _is_coroutine # For iscoroutinefunction(). - return wrapper diff --git a/tests/conftest.py b/tests/conftest.py index 0307e65d2722..32b6f98ca72f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,6 @@ import itertools import logging import sqlite3 import ssl -import sys import threading from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -108,11 +107,6 @@ asyncio.set_event_loop_policy(runner.HassEventLoopPolicy(False)) # Disable fixtures overriding our beautiful policy asyncio.set_event_loop_policy = lambda policy: None -if sys.version_info[:2] >= (3, 11): - from .asyncio_legacy import legacy_coroutine - - setattr(asyncio, "coroutine", legacy_coroutine) - def _utcnow() -> datetime.datetime: """Make utcnow patchable by freezegun.""" From e379aa23bdc3ec469fd363e300ec329b85da3158 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 15:26:29 -1000 Subject: [PATCH 0514/1058] Migrate StateAttributes to use a table manager (#89760) Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/core.py | 142 ++++------------ homeassistant/components/recorder/purge.py | 24 +-- homeassistant/components/recorder/queries.py | 11 -- .../recorder/table_managers/__init__.py | 66 +++++++- .../recorder/table_managers/event_data.py | 34 +--- .../recorder/table_managers/event_types.py | 26 +-- .../table_managers/state_attributes.py | 160 ++++++++++++++++++ .../recorder/table_managers/states_meta.py | 26 +-- tests/components/recorder/test_init.py | 12 +- 9 files changed, 274 insertions(+), 227 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/state_attributes.py diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 26cd5c3b8896..e7fdf645812f 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -11,10 +11,9 @@ import queue import sqlite3 import threading import time -from typing import Any, TypeVar, cast +from typing import Any, TypeVar import async_timeout -from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select from sqlalchemy.engine import Engine from sqlalchemy.exc import SQLAlchemyError @@ -30,7 +29,6 @@ from homeassistant.const import ( MATCH_ALL, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.entity import entity_sources from homeassistant.helpers.event import ( async_track_time_change, async_track_time_interval, @@ -40,7 +38,6 @@ from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util from homeassistant.util.enum import try_parse_enum -from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS from . import migration, statistics from .const import ( @@ -52,7 +49,6 @@ from .const import ( MAX_QUEUE_BACKLOG, MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, - SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, SupportedDialect, ) @@ -79,8 +75,6 @@ from .models import ( ) from .pool import POOL_SIZE, MutexPool, RecorderPool from .queries import ( - find_shared_attributes_id, - get_shared_attributes, has_entity_ids_to_migrate, has_event_type_to_migrate, has_events_context_ids_to_migrate, @@ -89,6 +83,7 @@ from .queries import ( from .run_history import RunHistory from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager +from .table_managers.state_attributes import StateAttributesManager from .table_managers.states_meta import StatesMetaManager from .tasks import ( AdjustLRUSizeTask, @@ -115,7 +110,6 @@ from .tasks import ( ) from .util import ( build_mysqldb_conv, - chunked, dburl_to_path, end_incomplete_runs, is_second_sunday, @@ -136,15 +130,6 @@ DEFAULT_URL = "sqlite:///{hass_config_path}" # States and Events objects EXPIRE_AFTER_COMMITS = 120 -# The number of attribute ids to cache in memory -# -# Based on: -# - The number of overlapping attributes -# - How frequently states with overlapping attributes will change -# - How much memory our low end hardware has -STATE_ATTRIBUTES_ID_CACHE_SIZE = 2048 - - SHUTDOWN_TASK = object() COMMIT_TASK = CommitTask() @@ -206,7 +191,6 @@ class Recorder(threading.Thread): self._queue_watch = threading.Event() self.engine: Engine | None = None self.run_history = RunHistory() - self._entity_sources = entity_sources(hass) # The entity_filter is exposed on the recorder instance so that # it can be used to see if an entity is being recorded and is called @@ -217,11 +201,12 @@ class Recorder(threading.Thread): self.schema_version = 0 self._commits_without_expire = 0 self._old_states: dict[str | None, States] = {} - self._state_attributes_ids: LRU = LRU(STATE_ATTRIBUTES_ID_CACHE_SIZE) self.event_data_manager = EventDataManager(self) self.event_type_manager = EventTypeManager(self) self.states_meta_manager = StatesMetaManager(self) - self._pending_state_attributes: dict[str, StateAttributes] = {} + self.state_attributes_manager = StateAttributesManager( + self, exclude_attributes_by_domain + ) self._pending_expunge: list[States] = [] self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None @@ -231,7 +216,6 @@ class Recorder(threading.Thread): self.migration_is_live = False self._database_lock_task: DatabaseLockTask | None = None self._db_executor: DBInterruptibleThreadPoolExecutor | None = None - self._exclude_attributes_by_domain = exclude_attributes_by_domain self._event_listener: CALLBACK_TYPE | None = None self._queue_watcher: CALLBACK_TYPE | None = None @@ -507,11 +491,9 @@ class Recorder(threading.Thread): If the number of entities has increased, increase the size of the LRU cache to avoid thrashing. """ - state_attributes_lru = self._state_attributes_ids - current_size = state_attributes_lru.get_size() new_size = self.hass.states.async_entity_ids_count() * 2 - if new_size > current_size: - state_attributes_lru.set_size(new_size) + self.state_attributes_manager.adjust_lru_size(new_size) + self.states_meta_manager.adjust_lru_size(new_size) @callback def async_periodic_statistics(self) -> None: @@ -776,33 +758,10 @@ class Recorder(threading.Thread): non_state_change_events.append(event_) assert self.event_session is not None - self._pre_process_state_change_events(state_change_events) self.event_data_manager.load(non_state_change_events, self.event_session) self.event_type_manager.load(non_state_change_events, self.event_session) self.states_meta_manager.load(state_change_events, self.event_session) - - def _pre_process_state_change_events(self, events: list[Event]) -> None: - """Load startup state attributes from the database. - - Since the _state_attributes_ids cache is empty at startup - we restore it from the database to avoid having to look up - the attributes in the database for every state change - until its primed. - """ - assert self.event_session is not None - if hashes := { - StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) - for event in events - if ( - shared_attrs_bytes := self._serialize_state_attributes_from_event(event) - ) - }: - with self.event_session.no_autoflush: - for hash_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for id_, shared_attrs in self.event_session.execute( - get_shared_attributes(hash_chunk) - ).fetchall(): - self._state_attributes_ids[shared_attrs] = id_ + self.state_attributes_manager.load(state_change_events, self.event_session) def _guarded_process_one_task_or_recover(self, task: RecorderTask) -> None: """Process a task, guarding against exceptions to ensure the loop does not collapse.""" @@ -932,24 +891,6 @@ class Recorder(threading.Thread): if not self.commit_interval: self._commit_event_session_or_retry() - def _find_shared_attr_in_db(self, attr_hash: int, shared_attrs: str) -> int | None: - """Find shared attributes in the db from the hash and shared_attrs.""" - # - # Avoid the event session being flushed since it will - # commit all the pending events and states to the database. - # - # The lookup has already have checked to see if the data is cached - # or going to be written in the next commit so there is no - # need to flush before checking the database. - # - assert self.event_session is not None - with self.event_session.no_autoflush: - if attributes_id := self.event_session.execute( - find_shared_attributes_id(attr_hash, shared_attrs) - ).first(): - return cast(int, attributes_id[0]) - return None - def _process_non_state_changed_event_into_session(self, event: Event) -> None: """Process any event into the session except state changed.""" session = self.event_session @@ -996,67 +937,53 @@ class Recorder(threading.Thread): session.add(dbevent) - def _serialize_state_attributes_from_event(self, event: Event) -> bytes | None: - """Serialize state changed event data.""" - try: - return StateAttributes.shared_attrs_bytes_from_event( - event, - self._entity_sources, - self._exclude_attributes_by_domain, - self.dialect_name, - ) - except JSON_ENCODE_EXCEPTIONS as ex: - _LOGGER.warning( - "State is not JSON serializable: %s: %s", - event.data.get("new_state"), - ex, - ) - return None - def _process_state_changed_event_into_session(self, event: Event) -> None: """Process a state_changed event into the session.""" + state_attributes_manager = self.state_attributes_manager dbstate = States.from_event(event) if (entity_id := dbstate.entity_id) is None or not ( - shared_attrs_bytes := self._serialize_state_attributes_from_event(event) + shared_attrs_bytes := state_attributes_manager.serialize_from_event(event) ): return assert self.event_session is not None - event_session = self.event_session + session = self.event_session # Map the entity_id to the StatesMeta table states_meta_manager = self.states_meta_manager if pending_states_meta := states_meta_manager.get_pending(entity_id): dbstate.states_meta_rel = pending_states_meta - elif metadata_id := states_meta_manager.get(entity_id, event_session, True): + elif metadata_id := states_meta_manager.get(entity_id, session, True): dbstate.metadata_id = metadata_id else: states_meta = StatesMeta(entity_id=entity_id) states_meta_manager.add_pending(states_meta) - event_session.add(states_meta) + session.add(states_meta) dbstate.states_meta_rel = states_meta + # Map the event data to the StateAttributes table shared_attrs = shared_attrs_bytes.decode("utf-8") dbstate.attributes = None # Matching attributes found in the pending commit - if pending_attributes := self._pending_state_attributes.get(shared_attrs): - dbstate.state_attributes = pending_attributes + if pending_event_data := state_attributes_manager.get_pending(shared_attrs): + dbstate.state_attributes = pending_event_data # Matching attributes id found in the cache - elif attributes_id := self._state_attributes_ids.get(shared_attrs): + elif ( + attributes_id := state_attributes_manager.get_from_cache(shared_attrs) + ) or ( + (hash_ := StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes)) + and ( + attributes_id := state_attributes_manager.get( + shared_attrs, hash_, session + ) + ) + ): dbstate.attributes_id = attributes_id else: - attr_hash = StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) - # Matching attributes found in the database - if attributes_id := self._find_shared_attr_in_db(attr_hash, shared_attrs): - dbstate.attributes_id = attributes_id - self._state_attributes_ids[shared_attrs] = attributes_id # No matching attributes found, save them in the DB - else: - dbstate_attributes = StateAttributes( - shared_attrs=shared_attrs, hash=attr_hash - ) - dbstate.state_attributes = dbstate_attributes - self._pending_state_attributes[shared_attrs] = dbstate_attributes - self.event_session.add(dbstate_attributes) + dbstate_attributes = StateAttributes(shared_attrs=shared_attrs, hash=hash_) + state_attributes_manager.add_pending(dbstate_attributes) + session.add(dbstate_attributes) + dbstate.state_attributes = dbstate_attributes if old_state := self._old_states.pop(entity_id, None): if old_state.state_id: @@ -1128,11 +1055,7 @@ class Recorder(threading.Thread): # and we now know the attributes_ids. We can save # many selects for matching attributes by loading them # into the LRU cache now. - for state_attr in self._pending_state_attributes.values(): - self._state_attributes_ids[ - state_attr.shared_attrs - ] = state_attr.attributes_id - self._pending_state_attributes = {} + self.state_attributes_manager.post_commit_pending() self.event_data_manager.post_commit_pending() self.event_type_manager.post_commit_pending() self.states_meta_manager.post_commit_pending() @@ -1158,8 +1081,7 @@ class Recorder(threading.Thread): def _close_event_session(self) -> None: """Close the event session.""" self._old_states.clear() - self._state_attributes_ids.clear() - self._pending_state_attributes.clear() + self.state_attributes_manager.reset() self.event_data_manager.reset() self.event_type_manager.reset() self.states_meta_manager.reset() diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 5dffead59780..08122b9fba7e 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -479,28 +479,6 @@ def _evict_purged_states_from_old_states_cache( old_states.pop(old_state_reversed[purged_state_id], None) -def _evict_purged_attributes_from_attributes_cache( - instance: Recorder, purged_attributes_ids: set[int] -) -> None: - """Evict purged attribute ids from the attribute ids cache.""" - # Make a map from attributes_id to the attributes json - state_attributes_ids = ( - instance._state_attributes_ids # pylint: disable=protected-access - ) - state_attributes_ids_reversed = { - attributes_id: attributes - for attributes, attributes_id in state_attributes_ids.items() - } - - # Evict any purged attributes from the state_attributes_ids cache - for purged_attribute_id in purged_attributes_ids.intersection( - state_attributes_ids_reversed - ): - state_attributes_ids.pop( - state_attributes_ids_reversed[purged_attribute_id], None - ) - - def _purge_batch_attributes_ids( instance: Recorder, session: Session, attributes_ids: set[int] ) -> None: @@ -512,7 +490,7 @@ def _purge_batch_attributes_ids( _LOGGER.debug("Deleted %s attribute states", deleted_rows) # Evict any entries in the state_attributes_ids cache referring to a purged state - _evict_purged_attributes_from_attributes_cache(instance, attributes_ids) + instance.state_attributes_manager.evict_purged(attributes_ids) def _purge_batch_data_ids( diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 0882da9d48c3..5a2c7040f43f 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -74,17 +74,6 @@ def find_states_metadata_ids(entity_ids: Iterable[str]) -> StatementLambdaElemen ) -def find_shared_attributes_id( - data_hash: int, shared_attrs: str -) -> StatementLambdaElement: - """Find an attributes_id by hash and shared_attrs.""" - return lambda_stmt( - lambda: select(StateAttributes.attributes_id) - .filter(StateAttributes.hash == data_hash) - .filter(StateAttributes.shared_attrs == shared_attrs) - ) - - def _state_attrs_exist(attr: int | None) -> Select: """Check if a state attributes id exists in the states table.""" # https://github.com/sqlalchemy/sqlalchemy/issues/9189 diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py index 50ea8f0e11f1..e56ee4f3415e 100644 --- a/homeassistant/components/recorder/table_managers/__init__.py +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -1,15 +1,75 @@ """Managers for each table.""" -from typing import TYPE_CHECKING +from collections.abc import MutableMapping +from typing import TYPE_CHECKING, Generic, TypeVar + +from lru import LRU # pylint: disable=no-name-in-module if TYPE_CHECKING: from ..core import Recorder +_DataT = TypeVar("_DataT") -class BaseTableManager: + +class BaseTableManager(Generic[_DataT]): """Base class for table managers.""" def __init__(self, recorder: "Recorder") -> None: - """Initialize the table manager.""" + """Initialize the table manager. + + The table manager is responsible for managing the id mappings + for a table. When data is committed to the database, the + manager will move the data from the pending to the id map. + """ self.active = False self.recorder = recorder + self._pending: dict[str, _DataT] = {} + self._id_map: MutableMapping[str, int] = {} + + def get_from_cache(self, data: str) -> int | None: + """Resolve data to the id without accessing the underlying database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._id_map.get(data) + + def get_pending(self, shared_data: str) -> _DataT | None: + """Get pending data that have not be assigned ids yet. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._pending.get(shared_data) + + def reset(self) -> None: + """Reset after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._id_map.clear() + self._pending.clear() + + +class BaseLRUTableManager(BaseTableManager[_DataT]): + """Base class for LRU table managers.""" + + def __init__(self, recorder: "Recorder", lru_size: int) -> None: + """Initialize the LRU table manager. + + We keep track of the most recently used items + and evict the least recently used items when the cache is full. + """ + super().__init__(recorder) + self._id_map: MutableMapping[str, int] = LRU(lru_size) + + def adjust_lru_size(self, new_size: int) -> None: + """Adjust the LRU cache size. + + This call is not thread-safe and must be called from the + recorder thread. + """ + lru: LRU = self._id_map + if new_size > lru.get_size(): + lru.set_size(new_size) diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index c877f08f8784..a99b25fe0b40 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -5,13 +5,12 @@ from collections.abc import Iterable import logging from typing import TYPE_CHECKING, cast -from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from . import BaseTableManager +from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventData from ..queries import get_shared_event_datas @@ -26,14 +25,12 @@ CACHE_SIZE = 2048 _LOGGER = logging.getLogger(__name__) -class EventDataManager(BaseTableManager): +class EventDataManager(BaseLRUTableManager[EventData]): """Manage the EventData table.""" def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" - self._id_map: dict[str, int] = LRU(CACHE_SIZE) - self._pending: dict[str, EventData] = {} - super().__init__(recorder) + super().__init__(recorder, CACHE_SIZE) self.active = True # always active def serialize_from_event(self, event: Event) -> bytes | None: @@ -67,14 +64,6 @@ class EventDataManager(BaseTableManager): """ return self.get_many(((shared_data, data_hash),), session)[shared_data] - def get_from_cache(self, shared_data: str) -> int | None: - """Resolve shared_data to the data_id without accessing the underlying database. - - This call is not thread-safe and must be called from the - recorder thread. - """ - return self._id_map.get(shared_data) - def get_many( self, shared_data_data_hashs: Iterable[tuple[str, int]], session: Session ) -> dict[str, int | None]: @@ -116,14 +105,6 @@ class EventDataManager(BaseTableManager): return results - def get_pending(self, shared_data: str) -> EventData | None: - """Get pending EventData that have not be assigned ids yet. - - This call is not thread-safe and must be called from the - recorder thread. - """ - return self._pending.get(shared_data) - def add_pending(self, db_event_data: EventData) -> None: """Add a pending EventData that will be committed at the next interval. @@ -144,15 +125,6 @@ class EventDataManager(BaseTableManager): self._id_map[shared_data] = db_event_data.data_id self._pending.clear() - def reset(self) -> None: - """Reset the event manager after the database has been reset or changed. - - This call is not thread-safe and must be called from the - recorder thread. - """ - self._id_map.clear() - self._pending.clear() - def evict_purged(self, data_ids: set[int]) -> None: """Evict purged data_ids from the cache when they are no longer used. diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index b31382336cc5..3cb3d9fad97f 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -4,12 +4,11 @@ from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, cast -from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event -from . import BaseTableManager +from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids @@ -22,14 +21,12 @@ if TYPE_CHECKING: CACHE_SIZE = 2048 -class EventTypeManager(BaseTableManager): +class EventTypeManager(BaseLRUTableManager[EventTypes]): """Manage the EventTypes table.""" def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" - self._id_map: dict[str, int] = LRU(CACHE_SIZE) - self._pending: dict[str, EventTypes] = {} - super().__init__(recorder) + super().__init__(recorder, CACHE_SIZE) def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory. @@ -80,14 +77,6 @@ class EventTypeManager(BaseTableManager): return results - def get_pending(self, event_type: str) -> EventTypes | None: - """Get pending EventTypes that have not be assigned ids yet. - - This call is not thread-safe and must be called from the - recorder thread. - """ - return self._pending.get(event_type) - def add_pending(self, db_event_type: EventTypes) -> None: """Add a pending EventTypes that will be committed at the next interval. @@ -108,15 +97,6 @@ class EventTypeManager(BaseTableManager): self._id_map[event_type] = db_event_types.event_type_id self._pending.clear() - def reset(self) -> None: - """Reset the event manager after the database has been reset or changed. - - This call is not thread-safe and must be called from the - recorder thread. - """ - self._id_map.clear() - self._pending.clear() - def evict_purged(self, event_types: Iterable[str]) -> None: """Evict purged event_types from the cache when they are no longer used. diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py new file mode 100644 index 000000000000..7489a6f165da --- /dev/null +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -0,0 +1,160 @@ +"""Support managing StateAttributes.""" +from __future__ import annotations + +from collections.abc import Iterable +import logging +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 . import BaseLRUTableManager +from ..const import SQLITE_MAX_BIND_VARS +from ..db_schema import StateAttributes +from ..queries import get_shared_attributes +from ..util import chunked + +if TYPE_CHECKING: + from ..core import Recorder + +# The number of attribute ids to cache in memory +# +# Based on: +# - The number of overlapping attributes +# - How frequently states with overlapping attributes will change +# - How much memory our low end hardware has +CACHE_SIZE = 2048 + +_LOGGER = logging.getLogger(__name__) + + +class StateAttributesManager(BaseLRUTableManager[StateAttributes]): + """Manage the StateAttributes table.""" + + def __init__( + self, recorder: Recorder, exclude_attributes_by_domain: dict[str, set[str]] + ) -> None: + """Initialize the event type manager.""" + super().__init__(recorder, CACHE_SIZE) + self.active = True # always active + self._exclude_attributes_by_domain = exclude_attributes_by_domain + 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._exclude_attributes_by_domain, + self.recorder.dialect_name, + ) + except JSON_ENCODE_EXCEPTIONS as ex: + _LOGGER.warning( + "State is not JSON serializable: %s: %s", + event.data.get("new_state"), + ex, + ) + return None + + def load(self, events: list[Event], session: Session) -> None: + """Load the shared_attrs to attributes_ids mapping into memory from events. + + This call is not thread-safe and must be called from the + recorder thread. + """ + if hashes := { + StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) + for event in events + if (shared_attrs_bytes := self.serialize_from_event(event)) + }: + self._load_from_hashes(hashes, session) + + def get(self, shared_attr: str, data_hash: int, session: Session) -> int | None: + """Resolve shared_attrs to the attributes_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self.get_many(((shared_attr, data_hash),), session)[shared_attr] + + def get_many( + self, shared_attrs_data_hashes: Iterable[tuple[str, int]], session: Session + ) -> dict[str, int | None]: + """Resolve shared_attrs to attributes_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ + results: dict[str, int | None] = {} + missing_hashes: set[int] = set() + for shared_attrs, data_hash in shared_attrs_data_hashes: + if (attributes_id := self._id_map.get(shared_attrs)) is None: + missing_hashes.add(data_hash) + + results[shared_attrs] = attributes_id + + if not missing_hashes: + return results + + return results | self._load_from_hashes(missing_hashes, session) + + def _load_from_hashes( + self, hashes: Iterable[int], session: Session + ) -> dict[str, int | None]: + """Load the shared_attrs to attributes_ids mapping into memory from a list of hashes. + + This call is not thread-safe and must be called from the + recorder thread. + """ + results: dict[str, int | None] = {} + with session.no_autoflush: + for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for attributes_id, shared_attrs in session.execute( + get_shared_attributes(hashs_chunk) + ): + results[shared_attrs] = self._id_map[shared_attrs] = cast( + int, attributes_id + ) + + return results + + def add_pending(self, db_state_attributes: StateAttributes) -> None: + """Add a pending StateAttributes that will be committed at the next interval. + + This call is not thread-safe and must be called from the + recorder thread. + """ + assert db_state_attributes.shared_attrs is not None + shared_attrs: str = db_state_attributes.shared_attrs + self._pending[shared_attrs] = db_state_attributes + + def post_commit_pending(self) -> None: + """Call after commit to load the attributes_ids of the new StateAttributes into the LRU. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for shared_attrs, db_state_attributes in self._pending.items(): + self._id_map[shared_attrs] = db_state_attributes.attributes_id + self._pending.clear() + + def evict_purged(self, attributes_ids: set[int]) -> None: + """Evict purged attributes_ids from the cache when they are no longer used. + + This call is not thread-safe and must be called from the + recorder thread. + """ + id_map = self._id_map + state_attributes_ids_reversed = { + attributes_id: shared_attrs + for shared_attrs, attributes_id in id_map.items() + } + # Evict any purged data from the cache + for purged_attributes_id in attributes_ids.intersection( + state_attributes_ids_reversed + ): + id_map.pop(state_attributes_ids_reversed[purged_attributes_id], None) diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index ded1690df134..b8b763aae330 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -4,12 +4,11 @@ from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, cast -from lru import LRU # pylint: disable=no-name-in-module from sqlalchemy.orm.session import Session from homeassistant.core import Event -from . import BaseTableManager +from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids @@ -21,15 +20,13 @@ if TYPE_CHECKING: CACHE_SIZE = 8192 -class StatesMetaManager(BaseTableManager): +class StatesMetaManager(BaseLRUTableManager[StatesMeta]): """Manage the StatesMeta table.""" def __init__(self, recorder: Recorder) -> None: """Initialize the states meta manager.""" - self._id_map: dict[str, int] = LRU(CACHE_SIZE) - self._pending: dict[str, StatesMeta] = {} self._did_first_load = False - super().__init__(recorder) + super().__init__(recorder, CACHE_SIZE) def load(self, events: list[Event], session: Session) -> None: """Load the entity_id to metadata_id mapping into memory. @@ -112,14 +109,6 @@ class StatesMetaManager(BaseTableManager): return results - def get_pending(self, entity_id: str) -> StatesMeta | None: - """Get pending StatesMeta that have not be assigned ids yet. - - This call is not thread-safe and must be called from the - recorder thread. - """ - return self._pending.get(entity_id) - def add_pending(self, db_states_meta: StatesMeta) -> None: """Add a pending StatesMeta that will be committed at the next interval. @@ -140,15 +129,6 @@ class StatesMetaManager(BaseTableManager): self._id_map[entity_id] = db_states_meta.metadata_id self._pending.clear() - def reset(self) -> None: - """Reset the states meta manager after the database has been reset or changed. - - This call is not thread-safe and must be called from the - recorder thread. - """ - self._id_map.clear() - self._pending.clear() - def evict_purged(self, entity_ids: Iterable[str]) -> None: """Evict purged event_types from the cache when they are no longer used. diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 5355931a76ab..ed804087d8ae 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -1872,9 +1872,11 @@ def test_deduplication_event_data_inside_commit_interval( assert all(event.data_id == first_data_id for event in events) -# Patch STATE_ATTRIBUTES_ID_CACHE_SIZE since otherwise +# Patch CACHE_SIZE since otherwise # the CI can fail because the test takes too long to run -@patch("homeassistant.components.recorder.core.STATE_ATTRIBUTES_ID_CACHE_SIZE", 5) +@patch( + "homeassistant.components.recorder.table_managers.state_attributes.CACHE_SIZE", 5 +) def test_deduplication_state_attributes_inside_commit_interval( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture ) -> None: @@ -2159,4 +2161,8 @@ async def test_lru_increases_with_many_entities( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await async_wait_recording_done(hass) - assert recorder_mock._state_attributes_ids.get_size() == mock_entity_count * 2 + assert ( + recorder_mock.state_attributes_manager._id_map.get_size() + == mock_entity_count * 2 + ) + assert recorder_mock.states_meta_manager._id_map.get_size() == mock_entity_count * 2 From 4080d6848943a4ee063f4d9435a707b9a5db1277 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 15:29:41 -1000 Subject: [PATCH 0515/1058] Fix logbook tests failing because time was not url encoded correctly (#89770) --- tests/components/logbook/test_init.py | 120 +++++++++++++++----------- 1 file changed, 71 insertions(+), 49 deletions(-) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index a3e240f682fe..c4f2f0a9ee22 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -417,7 +417,7 @@ async def test_logbook_view_period_entity( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -455,7 +455,7 @@ async def test_logbook_view_period_entity( # Tomorrow time 00:00:00 start = (dt_util.utcnow() + timedelta(days=1)).date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test tomorrow entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -512,12 +512,13 @@ async def test_logbook_describe_event( client = await hass_client() # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) results = await response.json() assert len(results) == 1 @@ -586,12 +587,13 @@ async def test_exclude_described_event( client = await hass_client() # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) results = await response.json() assert len(results) == 1 @@ -619,12 +621,13 @@ async def test_logbook_view_end_time_entity( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK response_json = await response.json() @@ -635,7 +638,8 @@ async def test_logbook_view_end_time_entity( # Test entries for 3 days with filter by entity_id end_time = start + timedelta(hours=72) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=switch.test" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat(), "entity": "switch.test"}, ) assert response.status == HTTPStatus.OK response_json = await response.json() @@ -644,15 +648,16 @@ async def test_logbook_view_end_time_entity( # Tomorrow time 00:00:00 start = dt_util.utcnow() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test entries from today to 3 days with filter by entity_id end_time = start_date + timedelta(hours=72) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=switch.test" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat(), "entity": "switch.test"}, ) - assert response.status == HTTPStatus.OK response_json = await response.json() + assert response.status == HTTPStatus.OK assert len(response_json) == 1 assert response_json[0]["entity_id"] == entity_id_test @@ -693,12 +698,13 @@ async def test_logbook_entity_filter_with_automations( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -712,7 +718,11 @@ async def test_logbook_entity_filter_with_automations( # Test entries for 3 days with filter by entity_id end_time = start + timedelta(hours=72) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=alarm_control_panel.area_001" + f"/api/logbook/{start_date.isoformat()}", + params={ + "end_time": end_time.isoformat(), + "entity": "alarm_control_panel.area_001", + }, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -721,12 +731,16 @@ async def test_logbook_entity_filter_with_automations( # Tomorrow time 00:00:00 start = dt_util.utcnow() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test entries from today to 3 days with filter by entity_id end_time = start_date + timedelta(hours=72) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=alarm_control_panel.area_002" + f"/api/logbook/{start_date.isoformat()}", + params={ + "end_time": end_time.isoformat(), + "entity": "alarm_control_panel.area_002", + }, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -760,12 +774,13 @@ async def test_logbook_entity_no_longer_in_state_machine( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -804,7 +819,7 @@ async def test_filter_continuous_sensor_values( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -845,7 +860,7 @@ async def test_exclude_new_entities( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -893,7 +908,7 @@ async def test_exclude_removed_entities( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -939,7 +954,7 @@ async def test_exclude_attribute_changes( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}") @@ -1053,12 +1068,13 @@ async def test_logbook_entity_context_id( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -1159,12 +1175,13 @@ async def test_logbook_context_id_automation_script_started_manually( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -1317,12 +1334,13 @@ async def test_logbook_entity_context_parent_id( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -1435,12 +1453,13 @@ async def test_logbook_context_from_template( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time - end_time = start + timedelta(hours=24) + end_time = start_date + timedelta(hours=24) response = await client.get( - f"/api/logbook/{start_date.isoformat()}?end_time={end_time}" + f"/api/logbook/{start_date.isoformat()}", + params={"end_time": end_time.isoformat()}, ) assert response.status == HTTPStatus.OK json_dict = await response.json() @@ -1518,7 +1537,7 @@ async def test_logbook_( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) @@ -1561,7 +1580,7 @@ async def test_logbook_many_entities_multiple_calls( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) end_time = start + timedelta(hours=24) for automation_id in range(5): @@ -1628,7 +1647,7 @@ async def test_custom_log_entry_discoverable_via_( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) @@ -1708,7 +1727,7 @@ async def test_logbook_multiple_entities( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) @@ -1781,7 +1800,7 @@ async def test_logbook_invalid_entity( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) @@ -2333,11 +2352,14 @@ async def _async_fetch_logbook(client, params=None): params = {} # Today time 00:00:00 - start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) - timedelta(hours=24) + now = dt_util.utcnow() + start = datetime(now.year, now.month, now.day, tzinfo=dt_util.UTC) + start_date = datetime( + start.year, start.month, start.day, tzinfo=dt_util.UTC + ) - timedelta(hours=24) if "end_time" not in params: - params["end_time"] = str(start + timedelta(hours=48)) + params["end_time"] = (start + timedelta(hours=48)).isoformat() # Test today entries without filters response = await client.get(f"/api/logbook/{start_date.isoformat()}", params=params) @@ -2825,7 +2847,7 @@ async def test_logbook_select_entities_context_id( # Today time 00:00:00 start = dt_util.utcnow().date() - start_date = datetime(start.year, start.month, start.day) + start_date = datetime(start.year, start.month, start.day, tzinfo=dt_util.UTC) # Test today entries with filter by end_time end_time = start + timedelta(hours=24) From 99d6b1fa5731195384fa2e055c966e6cb3753899 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 16:19:43 -1000 Subject: [PATCH 0516/1058] Migrate States to use a table manager (#89769) --- homeassistant/components/recorder/core.py | 30 +++--- homeassistant/components/recorder/purge.py | 20 +--- .../recorder/table_managers/states.py | 91 +++++++++++++++++++ tests/components/recorder/test_purge.py | 10 +- 4 files changed, 109 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/states.py diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index e7fdf645812f..3468acaed4c6 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -84,6 +84,7 @@ from .run_history import RunHistory from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager from .table_managers.state_attributes import StateAttributesManager +from .table_managers.states import StatesManager from .table_managers.states_meta import StatesMetaManager from .tasks import ( AdjustLRUSizeTask, @@ -200,14 +201,13 @@ class Recorder(threading.Thread): self.schema_version = 0 self._commits_without_expire = 0 - self._old_states: dict[str | None, States] = {} + self.states_manager = StatesManager() self.event_data_manager = EventDataManager(self) self.event_type_manager = EventTypeManager(self) self.states_meta_manager = StatesMetaManager(self) self.state_attributes_manager = StateAttributesManager( self, exclude_attributes_by_domain ) - self._pending_expunge: list[States] = [] self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None self._completed_first_database_setup: bool | None = None @@ -985,14 +985,13 @@ class Recorder(threading.Thread): session.add(dbstate_attributes) dbstate.state_attributes = dbstate_attributes - if old_state := self._old_states.pop(entity_id, None): - if old_state.state_id: - dbstate.old_state_id = old_state.state_id - else: - dbstate.old_state = old_state + states_manager = self.states_manager + if old_state := states_manager.pop_pending(entity_id): + dbstate.old_state = old_state + elif old_state_id := states_manager.pop_committed(entity_id): + dbstate.old_state_id = old_state_id if event.data.get("new_state"): - self._old_states[entity_id] = dbstate - self._pending_expunge.append(dbstate) + states_manager.add_pending(entity_id, dbstate) else: dbstate.state = None @@ -1043,18 +1042,11 @@ class Recorder(threading.Thread): self._commits_without_expire += 1 self.event_session.commit() - if self._pending_expunge: - for dbstate in self._pending_expunge: - # Expunge the state so its not expired - # until we use it later for dbstate.old_state - if dbstate in self.event_session: - self.event_session.expunge(dbstate) - self._pending_expunge = [] - # We just committed the state attributes to the database # and we now know the attributes_ids. We can save # many selects for matching attributes by loading them - # into the LRU cache now. + # into the LRU or committed now. + self.states_manager.post_commit_pending() self.state_attributes_manager.post_commit_pending() self.event_data_manager.post_commit_pending() self.event_type_manager.post_commit_pending() @@ -1080,7 +1072,7 @@ class Recorder(threading.Thread): def _close_event_session(self) -> None: """Close the event session.""" - self._old_states.clear() + self.states_manager.reset() self.state_attributes_manager.reset() self.event_data_manager.reset() self.event_type_manager.reset() diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 08122b9fba7e..528cb1247fd5 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -459,24 +459,7 @@ def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) _LOGGER.debug("Deleted %s states", deleted_rows) # Evict eny entries in the old_states cache referring to a purged state - _evict_purged_states_from_old_states_cache(instance, state_ids) - - -def _evict_purged_states_from_old_states_cache( - instance: Recorder, purged_state_ids: set[int] -) -> None: - """Evict purged states from the old states cache.""" - # Make a map from old_state_id to entity_id - old_states = instance._old_states # pylint: disable=protected-access - old_state_reversed = { - old_state.state_id: entity_id - for entity_id, old_state in old_states.items() - if old_state.state_id - } - - # Evict any purged state from the old states cache - for purged_state_id in purged_state_ids.intersection(old_state_reversed): - old_states.pop(old_state_reversed[purged_state_id], None) + instance.states_manager.evict_purged_state_ids(state_ids) def _purge_batch_attributes_ids( @@ -576,6 +559,7 @@ def _purge_old_entity_ids(instance: Recorder, session: Session) -> None: # Evict any entries in the event_type cache referring to a purged state instance.states_meta_manager.evict_purged(purge_entity_ids) + instance.states_manager.evict_purged_entity_ids(purge_entity_ids) def _purge_filtered_data(instance: Recorder, session: Session) -> bool: diff --git a/homeassistant/components/recorder/table_managers/states.py b/homeassistant/components/recorder/table_managers/states.py new file mode 100644 index 000000000000..fcfdcef08911 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/states.py @@ -0,0 +1,91 @@ +"""Support managing States.""" +from __future__ import annotations + +from ..db_schema import States + + +class StatesManager: + """Manage the states table.""" + + def __init__(self) -> None: + """Initialize the states manager for linking old_state_id.""" + self._pending: dict[str, States] = {} + self._last_committed_id: dict[str, int] = {} + + def pop_pending(self, entity_id: str) -> States | None: + """Pop a pending state. + + Pending states are states that are in the session but not yet committed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._pending.pop(entity_id, None) + + def pop_committed(self, entity_id: str) -> int | None: + """Pop a committed state. + + Committed states are states that have already been committed to the + database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + return self._last_committed_id.pop(entity_id, None) + + def add_pending(self, entity_id: str, state: States) -> None: + """Add a pending state. + + Pending states are states that are in the session but not yet committed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._pending[entity_id] = state + + def post_commit_pending(self) -> None: + """Call after commit to load the state_id of the new States into committed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + for entity_id, db_states in self._pending.items(): + self._last_committed_id[entity_id] = db_states.state_id + self._pending.clear() + + def reset(self) -> None: + """Reset after the database has been reset or changed. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._last_committed_id.clear() + self._pending.clear() + + def evict_purged_state_ids(self, purged_state_ids: set[int]) -> None: + """Evict purged states from the committed states. + + When we purge states we need to make sure the next call to record a state + does not link the old_state_id to the purged state. + """ + # Make a map from the committed state_id to the entity_id + last_committed_ids = self._last_committed_id + last_committed_ids_reversed = { + state_id: entity_id for entity_id, state_id in last_committed_ids.items() + } + + # Evict any purged state from the old states cache + for purged_state_id in purged_state_ids.intersection( + last_committed_ids_reversed + ): + last_committed_ids.pop(last_committed_ids_reversed[purged_state_id], None) + + def evict_purged_entity_ids(self, purged_entity_ids: set[str]) -> None: + """Evict purged entity_ids from the committed states. + + When we purge states we need to make sure the next call to record a state + does not link the old_state_id to the purged state. + """ + last_committed_ids = self._last_committed_id + for entity_id in purged_entity_ids: + last_committed_ids.pop(entity_id, None) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 2979b04e5c49..f268325b2176 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -82,7 +82,7 @@ async def test_purge_old_states( events = session.query(Events).filter(Events.event_type == "state_changed") assert events.count() == 0 - assert "test.recorder2" in instance._old_states + assert "test.recorder2" in instance.states_manager._last_committed_id purge_before = dt_util.utcnow() - timedelta(days=4) @@ -98,7 +98,7 @@ async def test_purge_old_states( assert states.count() == 2 assert state_attributes.count() == 1 - assert "test.recorder2" in instance._old_states + assert "test.recorder2" in instance.states_manager._last_committed_id states_after_purge = list(session.query(States)) # Since these states are deleted in batches, we can't guarantee the order @@ -115,7 +115,7 @@ async def test_purge_old_states( assert states.count() == 2 assert state_attributes.count() == 1 - assert "test.recorder2" in instance._old_states + assert "test.recorder2" in instance.states_manager._last_committed_id # run purge_old_data again purge_before = dt_util.utcnow() @@ -130,7 +130,7 @@ async def test_purge_old_states( assert states.count() == 0 assert state_attributes.count() == 0 - assert "test.recorder2" not in instance._old_states + assert "test.recorder2" not in instance.states_manager._last_committed_id # Add some more states await _add_test_states(hass) @@ -144,7 +144,7 @@ async def test_purge_old_states( events = session.query(Events).filter(Events.event_type == "state_changed") assert events.count() == 0 - assert "test.recorder2" in instance._old_states + assert "test.recorder2" in instance.states_manager._last_committed_id state_attributes = session.query(StateAttributes) assert state_attributes.count() == 3 From ed27dae173066670b3dd4f2baed23a928c2f3905 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 17:44:33 -1000 Subject: [PATCH 0517/1058] Small cleanups to recorder history (#89774) * Small cleanups to recorder history * Small cleanups to recorder history * fixes * flake8 cannot figure it out --- .../components/recorder/history/modern.py | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 6ac139cc7840..a6ca9adf7b51 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -5,7 +5,6 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Iterator, MutableMapping from datetime import datetime from itertools import groupby -import logging from operator import itemgetter from typing import Any, cast @@ -24,12 +23,7 @@ import homeassistant.util.dt as dt_util from ... import recorder from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta from ..filters import Filters -from ..models import ( - LazyState, - process_timestamp, - process_timestamp_to_utc_isoformat, - row_to_compressed_state, -) +from ..models import LazyState, process_timestamp, row_to_compressed_state from ..util import execute_stmt_lambda_element, session_scope from .const import ( IGNORE_DOMAINS_ENTITY_ID_LIKE, @@ -40,9 +34,6 @@ from .const import ( STATE_KEY, ) -_LOGGER = logging.getLogger(__name__) - - _BASE_STATES = ( States.metadata_id, States.state, @@ -710,7 +701,7 @@ def _sorted_states_to_dict( # Append all changes to it for metadata_id, group in states_iter: attr_cache: dict[str, dict[str, Any]] = {} - prev_state: Column | str + prev_state: Column | str | None = None if not (entity_id := metadata_id_to_entity_id.get(metadata_id)): continue ent_results = result[entity_id] @@ -741,6 +732,7 @@ def _sorted_states_to_dict( ) state_idx = field_map["state"] + last_updated_ts_idx = field_map["last_updated_ts"] # # minimal_response only makes sense with last_updated == last_updated @@ -749,29 +741,28 @@ def _sorted_states_to_dict( # # With minimal response we do not care about attribute # changes so we can filter out duplicate states - last_updated_ts_idx = field_map["last_updated_ts"] if compressed_state_format: - for row in group: - if (state := row[state_idx]) != prev_state: - ent_results.append( - { - attr_state: state, - attr_time: row[last_updated_ts_idx], - } - ) - prev_state = state + # Compressed state format uses the timestamp directly + ent_results.extend( + { + attr_state: (prev_state := state), + attr_time: row[last_updated_ts_idx], + } + for row in group + if (state := row[state_idx]) != prev_state + ) + continue - for row in group: - if (state := row[state_idx]) != prev_state: - ent_results.append( - { - attr_state: state, - attr_time: process_timestamp_to_utc_isoformat( - dt_util.utc_from_timestamp(row[last_updated_ts_idx]) - ), - } - ) - prev_state = state + # Non-compressed state format returns an ISO formatted string + _utc_from_timestamp = dt_util.utc_from_timestamp + ent_results.extend( + { + attr_state: (prev_state := state), # noqa: F841 + attr_time: _utc_from_timestamp(row[last_updated_ts_idx]).isoformat(), + } + for row in group + if (state := row[state_idx]) != prev_state + ) # If there are no states beyond the initial state, # the state a was never popped from initial_states From ecf6922ade48559d7a4b8667335a778f0aa4f957 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 17:47:26 -1000 Subject: [PATCH 0518/1058] Bump yalexs_ble to 2.1.0 (#89772) switches to using cryptography to reduce the number of deps changelog: https://github.com/bdraco/yalexs-ble/compare/v2.0.4...v2.1.0 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index dedfc9127a3a..eba6e2c1b391 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.2.7", "yalexs_ble==2.0.4"] + "requirements": ["yalexs==1.2.7", "yalexs_ble==2.1.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index e34ace05e154..e793fe272865 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.0.4"] + "requirements": ["yalexs-ble==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6b8b2f978062..8a4bb3ef53c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2670,13 +2670,13 @@ xs1-api-client==3.0.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.4 +yalexs-ble==2.1.0 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.4 +yalexs_ble==2.1.0 # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0591758b9f69..cc342ecabeef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1904,13 +1904,13 @@ xmltodict==0.13.0 yalesmartalarmclient==0.3.9 # homeassistant.components.yalexs_ble -yalexs-ble==2.0.4 +yalexs-ble==2.1.0 # homeassistant.components.august yalexs==1.2.7 # homeassistant.components.august -yalexs_ble==2.0.4 +yalexs_ble==2.1.0 # homeassistant.components.yeelight yeelight==0.7.10 From 2365a884d2f0fb835205e41a274a3fe9d0279e07 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 16 Mar 2023 04:48:00 +0100 Subject: [PATCH 0519/1058] Secure modbus hub_collect remains valid (#89684) Secure hub_collect remains valid. --- homeassistant/components/modbus/modbus.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index abc1508d7dca..b53cfda104ea 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -137,8 +137,10 @@ async def async_modbus_setup( for name in hubs: if not await hubs[name].async_setup(): return False + hub_collect = hass.data[DOMAIN] + else: + hass.data[DOMAIN] = hub_collect = {} - hass.data[DOMAIN] = hub_collect = {} for conf_hub in config[DOMAIN]: my_hub = ModbusHub(hass, conf_hub) hub_collect[conf_hub[CONF_NAME]] = my_hub From c707ddbf7cb8a2613e3223a41d09280965db5026 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Mar 2023 20:00:47 -1000 Subject: [PATCH 0520/1058] Bump aioesphomeapi to 13.5.1 (#89777) --- 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 54cdc23355f3..95b6c091d5f6 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.5.0", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.5.1", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a4bb3ef53c1..fb76c2107379 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.0 +aioesphomeapi==13.5.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc342ecabeef..6e88193d5618 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.0 +aioesphomeapi==13.5.1 # homeassistant.components.flo aioflo==2021.11.0 From 913156b0e021251b63835fe5c14be819f79de4f9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 08:00:21 +0100 Subject: [PATCH 0521/1058] Avoid lingering timer on script shutdown (#89753) --- homeassistant/helpers/script.py | 19 ++++++++++++----- tests/components/automation/test_init.py | 2 ++ tests/components/script/test_init.py | 1 + tests/conftest.py | 27 ++++++++++++++++++++++++ tests/helpers/test_script.py | 2 ++ 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 02fa9dc7806e..9ba4e7a9d88c 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -66,6 +66,7 @@ from homeassistant.const import ( from homeassistant.core import ( SERVICE_CALL_LIMIT, Context, + Event, HassJob, HomeAssistant, callback, @@ -1074,7 +1075,17 @@ class _QueuedScriptRun(_ScriptRun): super()._finish() -async def _async_stop_scripts_after_shutdown(hass, point_in_time): +@callback +def _schedule_stop_scripts_after_shutdown(hass: HomeAssistant) -> None: + """Stop running Script objects started after shutdown.""" + async_call_later( + hass, _SHUTDOWN_MAX_WAIT, partial(_async_stop_scripts_after_shutdown, hass) + ) + + +async def _async_stop_scripts_after_shutdown( + hass: HomeAssistant, point_in_time: datetime +) -> None: """Stop running Script objects started after shutdown.""" hass.data[DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED] = None running_scripts = [ @@ -1091,11 +1102,9 @@ async def _async_stop_scripts_after_shutdown(hass, point_in_time): ) -async def _async_stop_scripts_at_shutdown(hass, event): +async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> None: """Stop running Script objects started before shutdown.""" - async_call_later( - hass, _SHUTDOWN_MAX_WAIT, partial(_async_stop_scripts_after_shutdown, hass) - ) + _schedule_stop_scripts_after_shutdown(hass) running_scripts = [ script diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index ff4ede357a4f..b8a1c52f4731 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2208,6 +2208,7 @@ async def test_trigger_condition_explicit_id(hass: HomeAssistant, calls) -> None (SCRIPT_MODE_SINGLE, "script1: Already running"), ), ) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_recursive_automation_starting_script( hass: HomeAssistant, automation_mode, @@ -2318,6 +2319,7 @@ async def test_recursive_automation_starting_script( @pytest.mark.parametrize("automation_mode", SCRIPT_MODE_CHOICES) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_recursive_automation( hass: HomeAssistant, automation_mode, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 8712e4080118..dc88ae2f0f21 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -1127,6 +1127,7 @@ async def test_recursive_script_indirect( @pytest.mark.parametrize( "script_mode", [SCRIPT_MODE_PARALLEL, SCRIPT_MODE_QUEUED, SCRIPT_MODE_RESTART] ) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_recursive_script_turn_on( hass: HomeAssistant, script_mode, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 32b6f98ca72f..c4530bd5381e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -263,6 +263,33 @@ def expected_lingering_tasks() -> bool: return False +@pytest.fixture +def wait_for_stop_scripts_after_shutdown() -> bool: + """Add ability to bypass _schedule_stop_scripts_after_shutdown. + + _schedule_stop_scripts_after_shutdown leaves a lingering timer. + + Parametrize to True to bypass the pytest failure. + @pytest.mark.parametrize("wait_for_stop_scripts_at_shutdown", [True]) + """ + return False + + +@pytest.fixture(autouse=True) +def skip_stop_scripts( + wait_for_stop_scripts_after_shutdown: bool, +) -> Generator[None, None, None]: + """Add ability to bypass _schedule_stop_scripts_after_shutdown.""" + if wait_for_stop_scripts_after_shutdown: + yield + return + with patch( + "homeassistant.helpers.script._schedule_stop_scripts_after_shutdown", + AsyncMock(), + ): + yield + + @pytest.fixture(autouse=True) def verify_cleanup( event_loop: asyncio.AbstractEventLoop, expected_lingering_tasks: bool diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 0521bc722cde..5affce5d5015 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4213,6 +4213,7 @@ async def test_shutdown_at( assert_action_trace(expected_trace) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_shutdown_after( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -4251,6 +4252,7 @@ async def test_shutdown_after( assert_action_trace(expected_trace) +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_start_script_after_shutdown( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From a591f642589a708bd26939dcabb69763bc440735 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Mar 2023 09:46:40 +0100 Subject: [PATCH 0522/1058] Bump actions/checkout from 3.3.0 to 3.4.0 (#89778) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 +++++------ .github/workflows/ci.yaml | 34 +++++++++++++++--------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 45774642a5ce..bc21a2e3c7c3 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 with: fetch-depth: 0 @@ -67,7 +67,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 @@ -105,7 +105,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -249,7 +249,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set build additional args run: | @@ -292,7 +292,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 12884206a4f9..4ac1075fcb20 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -203,7 +203,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -248,7 +248,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -294,7 +294,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -343,7 +343,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -392,7 +392,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -430,7 +430,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -549,7 +549,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -617,7 +617,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -649,7 +649,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -682,7 +682,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -726,7 +726,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -792,7 +792,7 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -845,7 +845,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -971,7 +971,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1079,7 +1079,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1155,7 +1155,7 @@ jobs: - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 8f8244c49033..b8cbd9204bfa 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ae8ee1938e5b..108673828374 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -22,7 +22,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Get information id: info @@ -82,7 +82,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -119,7 +119,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.4.0 - name: Download env_file uses: actions/download-artifact@v3 From 8a58457203877a3bbad88086172d93b57985c212 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:07:42 +0100 Subject: [PATCH 0523/1058] Fix lingering timer in config entries test (#89787) --- tests/test_config_entries.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 12b77aded8f9..36fbaff150c6 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3210,6 +3210,9 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: assert len(mock_call.return_value.mock_calls) == 0 + # Cleanup to avoid lingering timer + entry.async_cancel_retry_setup() + @pytest.mark.parametrize( ("matchers", "reason"), From fec6236dd94a9ede52a40a1875814fa11a56db35 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:08:47 +0100 Subject: [PATCH 0524/1058] Add type hints to root tests (#89785) --- tests/test_bootstrap.py | 105 ++++++++-------- tests/test_config_entries.py | 226 +++++++++++++++++++++++++---------- 2 files changed, 214 insertions(+), 117 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 8fe35a20bc43..1ee60b71dada 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1,9 +1,10 @@ """Test the bootstrapping.""" - import asyncio +from collections.abc import Generator import glob import os -from unittest.mock import Mock, patch +from typing import Any +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -27,7 +28,7 @@ VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) @pytest.fixture(autouse=True) -def apply_mock_storage(hass_storage): +def apply_mock_storage(hass_storage: dict[str, Any]) -> None: """Apply the storage mock.""" @@ -37,7 +38,7 @@ async def apply_stop_hass(stop_hass: None) -> None: @pytest.fixture(autouse=True) -def mock_http_start_stop(): +def mock_http_start_stop() -> Generator[None, None, None]: """Mock HTTP start and stop.""" with patch( "homeassistant.components.http.start_http_server_and_save_config" @@ -416,8 +417,8 @@ async def test_setup_after_deps_not_present(hass: HomeAssistant) -> None: @pytest.fixture -def mock_is_virtual_env(): - """Mock enable logging.""" +def mock_is_virtual_env() -> Generator[Mock, None, None]: + """Mock is_virtual_env.""" with patch( "homeassistant.bootstrap.is_virtual_env", return_value=False ) as is_virtual_env: @@ -425,14 +426,14 @@ def mock_is_virtual_env(): @pytest.fixture -def mock_enable_logging(): +def mock_enable_logging() -> Generator[Mock, None, None]: """Mock enable logging.""" with patch("homeassistant.bootstrap.async_enable_logging") as enable_logging: yield enable_logging @pytest.fixture -def mock_mount_local_lib_path(): +def mock_mount_local_lib_path() -> Generator[AsyncMock, None, None]: """Mock enable logging.""" with patch( "homeassistant.bootstrap.async_mount_local_lib_path" @@ -441,7 +442,7 @@ def mock_mount_local_lib_path(): @pytest.fixture -def mock_process_ha_config_upgrade(): +def mock_process_ha_config_upgrade() -> Generator[Mock, None, None]: """Mock enable logging.""" with patch( "homeassistant.config.process_ha_config_upgrade" @@ -450,7 +451,7 @@ def mock_process_ha_config_upgrade(): @pytest.fixture -def mock_ensure_config_exists(): +def mock_ensure_config_exists() -> Generator[AsyncMock, None, None]: """Mock enable logging.""" with patch( "homeassistant.config.async_ensure_config_exists", return_value=True @@ -461,13 +462,13 @@ def mock_ensure_config_exists(): @pytest.mark.parametrize("hass_config", [{"browser": {}, "frontend": {}}]) async def test_setup_hass( mock_hass_config: None, - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop, + event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" verbose = Mock() @@ -511,13 +512,13 @@ async def test_setup_hass( @pytest.mark.parametrize("hass_config", [{"browser": {}, "frontend": {}}]) async def test_setup_hass_takes_longer_than_log_slow_startup( mock_hass_config: None, - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, caplog: pytest.LogCaptureFixture, - event_loop, + event_loop: asyncio.AbstractEventLoop, ) -> None: """Test it works.""" verbose = Mock() @@ -551,12 +552,12 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( async def test_setup_hass_invalid_yaml( - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + 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( @@ -579,12 +580,12 @@ async def test_setup_hass_invalid_yaml( async def test_setup_hass_config_dir_nonexistent( - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + 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 @@ -606,12 +607,12 @@ async def test_setup_hass_config_dir_nonexistent( async def test_setup_hass_safe_mode( - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + 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( @@ -641,12 +642,12 @@ async def test_setup_hass_safe_mode( @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) async def test_setup_hass_invalid_core_config( mock_hass_config: None, - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + 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.""" hass = await bootstrap.async_setup_hass( @@ -679,12 +680,12 @@ async def test_setup_hass_invalid_core_config( ) async def test_setup_safe_mode_if_no_frontend( mock_hass_config: None, - mock_enable_logging, - mock_is_virtual_env, - mock_mount_local_lib_path, - mock_ensure_config_exists, - mock_process_ha_config_upgrade, - event_loop, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + 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 safe mode if frontend didn't load.""" verbose = Mock() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 36fbaff150c6..29041730da20 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Generator from datetime import timedelta import logging from typing import Any @@ -26,6 +27,8 @@ from homeassistant.exceptions import ( HomeAssistantError, ) from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util import dt @@ -44,7 +47,7 @@ from .common import ( @pytest.fixture(autouse=True) -def mock_handlers(): +def mock_handlers() -> Generator[None, None, None]: """Mock config flows.""" class MockFlowHandler(config_entries.ConfigFlow): @@ -63,7 +66,7 @@ def mock_handlers(): @pytest.fixture -def manager(hass): +def manager(hass: HomeAssistant) -> config_entries.ConfigEntries: """Fixture of a loaded config manager.""" manager = config_entries.ConfigEntries(hass, {}) hass.config_entries = manager @@ -263,15 +266,21 @@ async def test_call_async_migrate_entry_failure_not_supported( assert not entry.supports_unload -async def test_remove_entry(hass: HomeAssistant, manager) -> None: +async def test_remove_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can remove an entry.""" - async def mock_setup_entry(hass, entry): + async def mock_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: """Mock setting up entry.""" hass.config_entries.async_setup_platforms(entry, ["light"]) return True - async def mock_unload_entry(hass, entry): + async def mock_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: """Mock unloading an entry.""" result = await hass.config_entries.async_unload_platforms(entry, ["light"]) assert result @@ -281,7 +290,11 @@ async def test_remove_entry(hass: HomeAssistant, manager) -> None: entity = MockEntity(unique_id="1234", name="Test Entity") - async def mock_setup_entry_platform(hass, entry, async_add_entities): + async def mock_setup_entry_platform( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: """Mock setting up platform.""" async_add_entities([entity]) @@ -347,7 +360,9 @@ async def test_remove_entry(hass: HomeAssistant, manager) -> None: assert not entity_entry_list -async def test_remove_entry_cancels_reauth(hass: HomeAssistant, manager) -> None: +async def test_remove_entry_cancels_reauth( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Tests that removing a config entry, also aborts existing reauth flows.""" entry = MockConfigEntry(title="test_title", domain="test") @@ -372,7 +387,7 @@ async def test_remove_entry_cancels_reauth(hass: HomeAssistant, manager) -> None async def test_remove_entry_handles_callback_error( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that exceptions in the remove callback are handled.""" mock_setup_entry = AsyncMock(return_value=True) @@ -406,7 +421,9 @@ async def test_remove_entry_handles_callback_error( assert [item.entry_id for item in manager.async_entries()] == [] -async def test_remove_entry_raises(hass: HomeAssistant, manager) -> None: +async def test_remove_entry_raises( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test if a component raises while removing entry.""" async def mock_unload_entry(hass, entry): @@ -433,7 +450,9 @@ async def test_remove_entry_raises(hass: HomeAssistant, manager) -> None: assert [item.entry_id for item in manager.async_entries()] == ["test1", "test3"] -async def test_remove_entry_if_not_loaded(hass: HomeAssistant, manager) -> None: +async def test_remove_entry_if_not_loaded( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can remove an entry that is not loaded.""" mock_unload_entry = AsyncMock(return_value=True) @@ -458,7 +477,7 @@ async def test_remove_entry_if_not_loaded(hass: HomeAssistant, manager) -> None: async def test_remove_entry_if_integration_deleted( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can remove an entry when the integration is deleted.""" mock_unload_entry = AsyncMock(return_value=True) @@ -481,7 +500,9 @@ async def test_remove_entry_if_integration_deleted( assert len(mock_unload_entry.mock_calls) == 0 -async def test_add_entry_calls_setup_entry(hass: HomeAssistant, manager) -> None: +async def test_add_entry_calls_setup_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test we call setup_config_entry.""" mock_setup_entry = AsyncMock(return_value=True) @@ -510,7 +531,7 @@ async def test_add_entry_calls_setup_entry(hass: HomeAssistant, manager) -> None assert p_entry.data == {"token": "supersecret"} -async def test_entries_gets_entries(manager) -> None: +async def test_entries_gets_entries(manager: config_entries.ConfigEntries) -> None: """Test entries are filtered by domain.""" MockConfigEntry(domain="test").add_to_manager(manager) entry1 = MockConfigEntry(domain="test2") @@ -521,7 +542,9 @@ async def test_entries_gets_entries(manager) -> None: assert manager.async_entries("test2") == [entry1, entry2] -async def test_domains_gets_domains_uniques(manager) -> None: +async def test_domains_gets_domains_uniques( + manager: config_entries.ConfigEntries, +) -> None: """Test we only return each domain once.""" MockConfigEntry(domain="test").add_to_manager(manager) MockConfigEntry(domain="test2").add_to_manager(manager) @@ -532,7 +555,9 @@ async def test_domains_gets_domains_uniques(manager) -> None: assert manager.async_domains() == ["test", "test2", "test3"] -async def test_domains_gets_domains_excludes_ignore_and_disabled(manager) -> None: +async def test_domains_gets_domains_excludes_ignore_and_disabled( + manager: config_entries.ConfigEntries, +) -> None: """Test we only return each domain once.""" MockConfigEntry(domain="test").add_to_manager(manager) MockConfigEntry(domain="test2").add_to_manager(manager) @@ -838,7 +863,7 @@ async def test_loading_default_config(hass: HomeAssistant) -> None: assert len(manager.async_entries()) == 0 -async def test_updating_entry_data(manager) -> None: +async def test_updating_entry_data(manager: config_entries.ConfigEntries) -> None: """Test that we can update an entry data.""" entry = MockConfigEntry( domain="test", @@ -854,7 +879,9 @@ async def test_updating_entry_data(manager) -> None: assert entry.data == {"second": True} -async def test_updating_entry_system_options(manager) -> None: +async def test_updating_entry_system_options( + manager: config_entries.ConfigEntries, +) -> None: """Test that we can update an entry data.""" entry = MockConfigEntry( domain="test", @@ -876,7 +903,7 @@ async def test_updating_entry_system_options(manager) -> None: async def test_update_entry_options_and_trigger_listener( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can update entry options and trigger listener.""" entry = MockConfigEntry(domain="test", options={"first": True}) @@ -1070,7 +1097,9 @@ async def test_create_entry_options(hass: HomeAssistant) -> None: assert entries[0].options == {"example": "option"} -async def test_entry_options(hass: HomeAssistant, manager) -> None: +async def test_entry_options( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can set options on an entry.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1104,7 +1133,9 @@ async def test_entry_options(hass: HomeAssistant, manager) -> None: assert entry.options == {"second": True} -async def test_entry_options_abort(hass: HomeAssistant, manager) -> None: +async def test_entry_options_abort( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can abort options flow.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1134,7 +1165,9 @@ async def test_entry_options_abort(hass: HomeAssistant, manager) -> None: ) -async def test_entry_setup_succeed(hass: HomeAssistant, manager) -> None: +async def test_entry_setup_succeed( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can setup an entry.""" entry = MockConfigEntry( domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED @@ -1166,7 +1199,11 @@ async def test_entry_setup_succeed(hass: HomeAssistant, manager) -> None: config_entries.ConfigEntryState.FAILED_UNLOAD, ), ) -async def test_entry_setup_invalid_state(hass: HomeAssistant, manager, state) -> None: +async def test_entry_setup_invalid_state( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we cannot setup an entry with invalid state.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1187,7 +1224,9 @@ async def test_entry_setup_invalid_state(hass: HomeAssistant, manager, state) -> assert entry.state is state -async def test_entry_unload_succeed(hass: HomeAssistant, manager) -> None: +async def test_entry_unload_succeed( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can unload an entry.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1209,7 +1248,11 @@ async def test_entry_unload_succeed(hass: HomeAssistant, manager) -> None: config_entries.ConfigEntryState.SETUP_RETRY, ), ) -async def test_entry_unload_failed_to_load(hass: HomeAssistant, manager, state) -> None: +async def test_entry_unload_failed_to_load( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we can unload an entry.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1230,7 +1273,11 @@ async def test_entry_unload_failed_to_load(hass: HomeAssistant, manager, state) config_entries.ConfigEntryState.FAILED_UNLOAD, ), ) -async def test_entry_unload_invalid_state(hass: HomeAssistant, manager, state) -> None: +async def test_entry_unload_invalid_state( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we cannot unload an entry with invalid state.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1246,7 +1293,9 @@ async def test_entry_unload_invalid_state(hass: HomeAssistant, manager, state) - assert entry.state is state -async def test_entry_reload_succeed(hass: HomeAssistant, manager) -> None: +async def test_entry_reload_succeed( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can reload an entry.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1281,7 +1330,11 @@ async def test_entry_reload_succeed(hass: HomeAssistant, manager) -> None: config_entries.ConfigEntryState.SETUP_RETRY, ), ) -async def test_entry_reload_not_loaded(hass: HomeAssistant, manager, state) -> None: +async def test_entry_reload_not_loaded( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we can reload an entry.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1315,7 +1368,11 @@ async def test_entry_reload_not_loaded(hass: HomeAssistant, manager, state) -> N config_entries.ConfigEntryState.FAILED_UNLOAD, ), ) -async def test_entry_reload_error(hass: HomeAssistant, manager, state) -> None: +async def test_entry_reload_error( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + state: config_entries.ConfigEntryState, +) -> None: """Test that we can reload an entry.""" entry = MockConfigEntry(domain="comp", state=state) entry.add_to_hass(hass) @@ -1344,7 +1401,9 @@ async def test_entry_reload_error(hass: HomeAssistant, manager, state) -> None: assert entry.state == state -async def test_entry_disable_succeed(hass: HomeAssistant, manager) -> None: +async def test_entry_disable_succeed( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can disable an entry.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -1382,7 +1441,7 @@ async def test_entry_disable_succeed(hass: HomeAssistant, manager) -> None: async def test_entry_disable_without_reload_support( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can disable an entry without reload support.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) @@ -1421,7 +1480,7 @@ async def test_entry_disable_without_reload_support( async def test_entry_enable_without_reload_support( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can disable an entry without reload support.""" entry = MockConfigEntry( @@ -1550,7 +1609,9 @@ async def test_reload_entry_entity_registry_works( assert len(mock_unload_entry.mock_calls) == 2 -async def test_unique_id_persisted(hass: HomeAssistant, manager) -> None: +async def test_unique_id_persisted( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that a unique ID is stored in the config entry.""" mock_setup_entry = AsyncMock(return_value=True) @@ -1579,7 +1640,9 @@ async def test_unique_id_persisted(hass: HomeAssistant, manager) -> None: assert p_entry.unique_id == "mock-unique-id" -async def test_unique_id_existing_entry(hass: HomeAssistant, manager) -> None: +async def test_unique_id_existing_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we remove an entry if there already is an entry with unique ID.""" hass.config.components.add("comp") MockConfigEntry( @@ -1632,7 +1695,9 @@ async def test_unique_id_existing_entry(hass: HomeAssistant, manager) -> None: assert len(async_remove_entry.mock_calls) == 1 -async def test_entry_id_existing_entry(hass: HomeAssistant, manager) -> None: +async def test_entry_id_existing_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we throw when the entry id collides.""" collide_entry_id = "collide" hass.config.components.add("comp") @@ -1670,7 +1735,7 @@ async def test_entry_id_existing_entry(hass: HomeAssistant, manager) -> None: async def test_unique_id_update_existing_entry_without_reload( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we update an entry if there already is an entry with unique ID.""" hass.config.components.add("comp") @@ -1716,7 +1781,7 @@ async def test_unique_id_update_existing_entry_without_reload( async def test_unique_id_update_existing_entry_with_reload( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we update an entry if there already is an entry with unique ID and we reload on changes.""" hass.config.components.add("comp") @@ -1780,7 +1845,7 @@ async def test_unique_id_update_existing_entry_with_reload( async def test_unique_id_from_discovery_in_setup_retry( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we reload when in a setup retry state from discovery.""" hass.config.components.add("comp") @@ -1851,7 +1916,7 @@ async def test_unique_id_from_discovery_in_setup_retry( async def test_unique_id_not_update_existing_entry( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we do not update an entry if existing entry has the data.""" hass.config.components.add("comp") @@ -1895,7 +1960,9 @@ async def test_unique_id_not_update_existing_entry( assert len(async_reload.mock_calls) == 0 -async def test_unique_id_in_progress(hass: HomeAssistant, manager) -> None: +async def test_unique_id_in_progress( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we abort if there is already a flow in progress with same unique id.""" mock_integration(hass, MockModule("comp")) mock_entity_platform(hass, "config_flow.comp", None) @@ -1926,7 +1993,9 @@ async def test_unique_id_in_progress(hass: HomeAssistant, manager) -> None: assert result2["reason"] == "already_in_progress" -async def test_finish_flow_aborts_progress(hass: HomeAssistant, manager) -> None: +async def test_finish_flow_aborts_progress( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that when finishing a flow, we abort other flows in progress with unique ID.""" mock_integration( hass, @@ -1965,7 +2034,9 @@ async def test_finish_flow_aborts_progress(hass: HomeAssistant, manager) -> None assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_unique_id_ignore(hass: HomeAssistant, manager) -> None: +async def test_unique_id_ignore( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can ignore flows that are in progress and have a unique ID.""" async_setup_entry = AsyncMock(return_value=False) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2008,7 +2079,9 @@ async def test_unique_id_ignore(hass: HomeAssistant, manager) -> None: assert entry.title == "Ignored Title" -async def test_manual_add_overrides_ignored_entry(hass: HomeAssistant, manager) -> None: +async def test_manual_add_overrides_ignored_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can ignore manually add entry, overriding ignored entry.""" hass.config.components.add("comp") entry = MockConfigEntry( @@ -2054,7 +2127,7 @@ async def test_manual_add_overrides_ignored_entry(hass: HomeAssistant, manager) async def test_manual_add_overrides_ignored_entry_singleton( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can ignore manually add entry, overriding ignored entry.""" hass.config.components.add("comp") @@ -2095,7 +2168,7 @@ async def test_manual_add_overrides_ignored_entry_singleton( async def test__async_current_entries_does_not_skip_ignore_non_user( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that _async_current_entries does not skip ignore by default for non user step.""" hass.config.components.add("comp") @@ -2132,7 +2205,7 @@ async def test__async_current_entries_does_not_skip_ignore_non_user( async def test__async_current_entries_explicit_skip_ignore( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that _async_current_entries can explicitly include ignore.""" hass.config.components.add("comp") @@ -2173,7 +2246,7 @@ async def test__async_current_entries_explicit_skip_ignore( async def test__async_current_entries_explicit_include_ignore( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that _async_current_entries can explicitly include ignore.""" hass.config.components.add("comp") @@ -2209,7 +2282,9 @@ async def test__async_current_entries_explicit_include_ignore( assert len(mock_setup_entry.mock_calls) == 0 -async def test_unignore_step_form(hass: HomeAssistant, manager) -> None: +async def test_unignore_step_form( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2254,7 +2329,9 @@ async def test_unignore_step_form(hass: HomeAssistant, manager) -> None: assert len(hass.config_entries.async_entries("comp")) == 0 -async def test_unignore_create_entry(hass: HomeAssistant, manager) -> None: +async def test_unignore_create_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2302,7 +2379,9 @@ async def test_unignore_create_entry(hass: HomeAssistant, manager) -> None: assert len(hass.config_entries.flow.async_progress_by_handler("comp")) == 0 -async def test_unignore_default_impl(hass: HomeAssistant, manager) -> None: +async def test_unignore_default_impl( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that resdicovery is a no-op by default.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2334,7 +2413,9 @@ async def test_unignore_default_impl(hass: HomeAssistant, manager) -> None: assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_partial_flows_hidden(hass: HomeAssistant, manager) -> None: +async def test_partial_flows_hidden( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that flows that don't have a cur_step and haven't finished initing are hidden.""" async_setup_entry = AsyncMock(return_value=True) mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) @@ -2395,7 +2476,7 @@ async def test_partial_flows_hidden(hass: HomeAssistant, manager) -> None: async def test_async_setup_init_entry(hass: HomeAssistant) -> None: """Test a config entry being initialized during integration setup.""" - async def mock_async_setup(hass, config): + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Mock setup.""" hass.async_create_task( hass.config_entries.flow.async_init( @@ -2576,7 +2657,9 @@ async def test_async_setup_update_entry(hass: HomeAssistant) -> None: ), ) async def test_flow_with_default_discovery( - hass: HomeAssistant, manager, discovery_source + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + discovery_source: tuple[str, dict | BaseServiceInfo], ) -> None: """Test that finishing a default discovery flow removes the unique ID in the entry.""" mock_integration( @@ -2626,7 +2709,7 @@ async def test_flow_with_default_discovery( async def test_flow_with_default_discovery_with_unique_id( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test discovery flow using the default discovery is ignored when unique ID is set.""" mock_integration(hass, MockModule("comp")) @@ -2656,7 +2739,7 @@ async def test_flow_with_default_discovery_with_unique_id( async def test_default_discovery_abort_existing_entries( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that a flow without discovery implementation aborts when a config entry exists.""" hass.config.components.add("comp") @@ -2679,7 +2762,9 @@ async def test_default_discovery_abort_existing_entries( assert result["reason"] == "already_configured" -async def test_default_discovery_in_progress(hass: HomeAssistant, manager) -> None: +async def test_default_discovery_in_progress( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test that a flow using default discovery can only be triggered once.""" mock_integration(hass, MockModule("comp")) mock_entity_platform(hass, "config_flow.comp", None) @@ -2715,7 +2800,7 @@ async def test_default_discovery_in_progress(hass: HomeAssistant, manager) -> No async def test_default_discovery_abort_on_new_unique_flow( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that a flow using default discovery is aborted when a second flow with unique ID is created.""" mock_integration(hass, MockModule("comp")) @@ -2754,7 +2839,7 @@ async def test_default_discovery_abort_on_new_unique_flow( async def test_default_discovery_abort_on_user_flow_complete( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that a flow using default discovery is aborted when a second flow completes.""" mock_integration(hass, MockModule("comp")) @@ -2804,7 +2889,9 @@ async def test_default_discovery_abort_on_user_flow_complete( assert len(flows) == 0 -async def test_flow_same_device_multiple_sources(hass: HomeAssistant, manager) -> None: +async def test_flow_same_device_multiple_sources( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test discovery of the same devices from multiple discovery sources.""" mock_integration( hass, @@ -2872,7 +2959,9 @@ async def test_flow_same_device_multiple_sources(hass: HomeAssistant, manager) - assert entry.unique_id == "thisid" -async def test_updating_entry_with_and_without_changes(manager) -> None: +async def test_updating_entry_with_and_without_changes( + manager: config_entries.ConfigEntries, +) -> None: """Test that we can update an entry data.""" entry = MockConfigEntry( domain="test", @@ -2900,7 +2989,7 @@ async def test_updating_entry_with_and_without_changes(manager) -> None: async def test_entry_reload_calls_on_unload_listeners( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test reload calls the on unload listeners.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) @@ -3239,7 +3328,10 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: ], ) async def test__async_abort_entries_match( - hass: HomeAssistant, manager, matchers, reason + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + matchers: dict[str, str], + reason: str, ) -> None: """Test aborting if matching config entries exist.""" MockConfigEntry( @@ -3336,7 +3428,9 @@ async def test_deprecated_disabled_by_str_ctor( async def test_deprecated_disabled_by_str_set( - hass: HomeAssistant, manager, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, ) -> None: """Test deprecated str set disabled_by enumizes and logs a warning.""" entry = MockConfigEntry() @@ -3348,7 +3442,9 @@ async def test_deprecated_disabled_by_str_set( assert " str for config entry disabled_by. This is deprecated " in caplog.text -async def test_entry_reload_concurrency(hass: HomeAssistant, manager) -> None: +async def test_entry_reload_concurrency( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: """Test multiple reload calls do not cause a reload race.""" entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) @@ -3457,7 +3553,7 @@ async def test_unique_id_update_while_setup_in_progress( async def test_disallow_entry_reload_with_setup_in_progresss( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test we do not allow reload while the config entry is still setting up.""" entry = MockConfigEntry( @@ -3638,7 +3734,7 @@ async def test_options_flow_options_not_mutated() -> None: async def test_initializing_flows_canceled_on_shutdown( - hass: HomeAssistant, manager + hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that initializing flows are canceled on shutdown.""" From 69e85b3216ce96216b9821468bce96838418f997 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:09:54 +0100 Subject: [PATCH 0525/1058] Fix SFR Box diagnostics (#89783) --- .../components/sfr_box/diagnostics.py | 10 ++- .../sfr_box/snapshots/test_diagnostics.ambr | 61 ++++++++++++++++++- tests/components/sfr_box/test_diagnostics.py | 5 ++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index e85bd602b71f..1fb980532679 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -26,13 +26,17 @@ async def async_get_config_entry_diagnostics( "data": dict(entry.data), }, "data": { - "dsl": async_redact_data(dataclasses.asdict(data.dsl.data), TO_REDACT), + "dsl": async_redact_data( + dataclasses.asdict(await data.system.box.dsl_get_info()), TO_REDACT + ), "ftth": async_redact_data( dataclasses.asdict(await data.system.box.ftth_get_info()), TO_REDACT ), "system": async_redact_data( - dataclasses.asdict(data.system.data), TO_REDACT + dataclasses.asdict(await data.system.box.system_get_info()), TO_REDACT + ), + "wan": async_redact_data( + dataclasses.asdict(await data.system.box.wan_get_info()), TO_REDACT ), - "wan": async_redact_data(dataclasses.asdict(data.wan.data), TO_REDACT), }, } diff --git a/tests/components/sfr_box/snapshots/test_diagnostics.ambr b/tests/components/sfr_box/snapshots/test_diagnostics.ambr index fe9268358f90..22a914f8a79d 100644 --- a/tests/components/sfr_box/snapshots/test_diagnostics.ambr +++ b/tests/components/sfr_box/snapshots/test_diagnostics.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entry_diagnostics +# name: test_entry_diagnostics[adsl] dict({ 'data': dict({ 'dsl': dict({ @@ -58,3 +58,62 @@ }), }) # --- +# name: test_entry_diagnostics[ftth] + dict({ + 'data': dict({ + 'dsl': dict({ + 'attenuation_down': 28.5, + 'attenuation_up': 20.8, + 'counter': 16, + 'crc': 0, + 'line_status': 'No Defect', + 'linemode': 'ADSL2+', + 'noise_down': 5.8, + 'noise_up': 6.0, + 'rate_down': 5549, + 'rate_up': 187, + 'status': 'up', + 'training': 'Showtime', + 'uptime': 450796, + }), + 'ftth': dict({ + 'status': 'down', + 'wanfibre': 'out', + }), + 'system': dict({ + 'alimvoltage': 12251, + 'current_datetime': '202212282233', + 'idur': 'RP3P85K', + 'mac_addr': '**REDACTED**', + 'net_infra': 'ftth', + 'net_mode': 'router', + 'product_id': 'NB6VAC-FXC-r0', + 'refclient': '', + 'serial_number': '**REDACTED**', + 'temperature': 27560, + 'uptime': 2353575, + 'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8', + 'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p', + 'version_mainfirmware': 'NB6VAC-MAIN-R4.0.44k', + 'version_rescuefirmware': 'NB6VAC-MAIN-R4.0.44k', + }), + 'wan': dict({ + 'infra': 'adsl', + 'infra6': '', + 'ip_addr': '**REDACTED**', + 'ipv6_addr': '', + 'mode': 'adsl/routed', + 'status': 'up', + 'status6': 'down', + 'uptime': 297464, + 'uptime6': None, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '192.168.0.1', + }), + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/sfr_box/test_diagnostics.py b/tests/components/sfr_box/test_diagnostics.py index 37e3ba9487f0..a433236ab7a7 100644 --- a/tests/components/sfr_box/test_diagnostics.py +++ b/tests/components/sfr_box/test_diagnostics.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from sfrbox_api.models import SystemInfo from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -23,13 +24,17 @@ def override_platforms() -> Generator[None, None, None]: yield +@pytest.mark.parametrize("net_infra", ["adsl", "ftth"]) async def test_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + system_get_info: SystemInfo, + net_infra: str, ) -> None: """Test config entry diagnostics.""" + system_get_info.net_infra = net_infra await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 46a5aa71eca10c0fd43ffa129dfad299a08b7988 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:10:56 +0100 Subject: [PATCH 0526/1058] Add type hints to helper tests (#89784) --- tests/helpers/test_config_entry_flow.py | 63 ++++++++++++++++--------- tests/helpers/test_entity.py | 5 +- tests/helpers/test_entity_component.py | 22 +++++++-- tests/helpers/test_entity_platform.py | 25 +++++++--- tests/helpers/test_event.py | 21 ++++++--- 5 files changed, 97 insertions(+), 39 deletions(-) diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 8909f18e544b..90d8030be79a 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -1,4 +1,5 @@ """Tests for the Config Entry Flow helper.""" +from collections.abc import Generator from unittest.mock import Mock, PropertyMock, patch import pytest @@ -17,11 +18,11 @@ from tests.common import ( @pytest.fixture -def discovery_flow_conf(hass): +def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool], None, None]: """Register a handler.""" handler_conf = {"discovered": False} - async def has_discovered_devices(hass): + async def has_discovered_devices(hass: HomeAssistant) -> bool: """Mock if we have discovered devices.""" return handler_conf["discovered"] @@ -33,17 +34,19 @@ def discovery_flow_conf(hass): @pytest.fixture -def webhook_flow_conf(hass): +def webhook_flow_conf(hass: HomeAssistant) -> Generator[None, None, None]: """Register a handler.""" with patch.dict(config_entries.HANDLERS): config_entry_flow.register_webhook_flow("test_single", "Test Single", {}, False) config_entry_flow.register_webhook_flow( "test_multiple", "Test Multiple", {}, True ) - yield {} + yield -async def test_single_entry_allowed(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_single_entry_allowed( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test only a single entry is allowed.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -56,7 +59,9 @@ async def test_single_entry_allowed(hass: HomeAssistant, discovery_flow_conf) -> assert result["reason"] == "single_instance_allowed" -async def test_user_no_devices_found(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_user_no_devices_found( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test if no devices found.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -67,7 +72,9 @@ async def test_user_no_devices_found(hass: HomeAssistant, discovery_flow_conf) - assert result["reason"] == "no_devices_found" -async def test_user_has_confirmation(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_user_has_confirmation( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test user requires confirmation to setup.""" discovery_flow_conf["discovered"] = True mock_entity_platform(hass, "config_flow.test", None) @@ -104,7 +111,7 @@ async def test_user_has_confirmation(hass: HomeAssistant, discovery_flow_conf) - ], ) async def test_discovery_single_instance( - hass: HomeAssistant, discovery_flow_conf, source + hass: HomeAssistant, discovery_flow_conf: dict[str, bool], source: str ) -> None: """Test we not allow duplicates.""" flow = config_entries.HANDLERS["test"]() @@ -130,7 +137,7 @@ async def test_discovery_single_instance( ], ) async def test_discovery_confirmation( - hass: HomeAssistant, discovery_flow_conf, source + hass: HomeAssistant, discovery_flow_conf: dict[str, bool], source: str ) -> None: """Test we ask for confirmation via discovery.""" flow = config_entries.HANDLERS["test"]() @@ -158,7 +165,7 @@ async def test_discovery_confirmation( ], ) async def test_discovery_during_onboarding( - hass: HomeAssistant, discovery_flow_conf, source + hass: HomeAssistant, discovery_flow_conf: dict[str, bool], source: str ) -> None: """Test we create config entry via discovery during onboarding.""" flow = config_entries.HANDLERS["test"]() @@ -173,7 +180,9 @@ async def test_discovery_during_onboarding( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY -async def test_multiple_discoveries(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_multiple_discoveries( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test we only create one instance for multiple discoveries.""" mock_entity_platform(hass, "config_flow.test", None) @@ -189,7 +198,9 @@ async def test_multiple_discoveries(hass: HomeAssistant, discovery_flow_conf) -> assert result["type"] == data_entry_flow.FlowResultType.ABORT -async def test_only_one_in_progress(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_only_one_in_progress( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test a user initialized one will finish and cancel discovered one.""" mock_entity_platform(hass, "config_flow.test", None) @@ -215,7 +226,9 @@ async def test_only_one_in_progress(hass: HomeAssistant, discovery_flow_conf) -> assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_import_abort_discovery(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_import_abort_discovery( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test import will finish and cancel discovered one.""" mock_entity_platform(hass, "config_flow.test", None) @@ -236,7 +249,9 @@ async def test_import_abort_discovery(hass: HomeAssistant, discovery_flow_conf) assert len(hass.config_entries.flow.async_progress()) == 0 -async def test_import_no_confirmation(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_import_no_confirmation( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test import requires no confirmation to set up.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -247,7 +262,9 @@ async def test_import_no_confirmation(hass: HomeAssistant, discovery_flow_conf) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY -async def test_import_single_instance(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_import_single_instance( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test import doesn't create second instance.""" flow = config_entries.HANDLERS["test"]() flow.hass = hass @@ -259,7 +276,9 @@ async def test_import_single_instance(hass: HomeAssistant, discovery_flow_conf) assert result["type"] == data_entry_flow.FlowResultType.ABORT -async def test_ignored_discoveries(hass: HomeAssistant, discovery_flow_conf) -> None: +async def test_ignored_discoveries( + hass: HomeAssistant, discovery_flow_conf: dict[str, bool] +) -> None: """Test we can ignore discovered entries.""" mock_entity_platform(hass, "config_flow.test", None) @@ -292,7 +311,7 @@ async def test_ignored_discoveries(hass: HomeAssistant, discovery_flow_conf) -> async def test_webhook_single_entry_allowed( - hass: HomeAssistant, webhook_flow_conf + hass: HomeAssistant, webhook_flow_conf: None ) -> None: """Test only a single entry is allowed.""" flow = config_entries.HANDLERS["test_single"]() @@ -306,7 +325,7 @@ async def test_webhook_single_entry_allowed( async def test_webhook_multiple_entries_allowed( - hass: HomeAssistant, webhook_flow_conf + hass: HomeAssistant, webhook_flow_conf: None ) -> None: """Test multiple entries are allowed when specified.""" flow = config_entries.HANDLERS["test_multiple"]() @@ -320,7 +339,7 @@ async def test_webhook_multiple_entries_allowed( async def test_webhook_config_flow_registers_webhook( - hass: HomeAssistant, webhook_flow_conf + hass: HomeAssistant, webhook_flow_conf: None ) -> None: """Test setting up an entry creates a webhook.""" flow = config_entries.HANDLERS["test_single"]() @@ -336,7 +355,9 @@ async def test_webhook_config_flow_registers_webhook( assert result["data"]["webhook_id"] is not None -async def test_webhook_create_cloudhook(hass: HomeAssistant, webhook_flow_conf) -> None: +async def test_webhook_create_cloudhook( + hass: HomeAssistant, webhook_flow_conf: None +) -> None: """Test cloudhook will be created if subscribed.""" assert await setup.async_setup_component(hass, "cloud", {}) @@ -390,7 +411,7 @@ async def test_webhook_create_cloudhook(hass: HomeAssistant, webhook_flow_conf) async def test_webhook_create_cloudhook_aborts_not_connected( - hass: HomeAssistant, webhook_flow_conf + hass: HomeAssistant, webhook_flow_conf: None ) -> None: """Test cloudhook aborts if subscribed but not connected.""" assert await setup.async_setup_component(hass, "cloud", {}) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 4b505c894b53..2b9a332e825c 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -914,7 +914,10 @@ async def test_entity_description_fallback() -> None: ), ) async def test_friendly_name( - hass: HomeAssistant, has_entity_name, entity_name, expected_friendly_name + hass: HomeAssistant, + has_entity_name: bool, + entity_name: str | None, + expected_friendly_name: str | None, ) -> None: """Test entity_id is influenced by entity name.""" diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index ac5108cb28f0..018fb6c9372f 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -18,6 +18,8 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import discovery from homeassistant.helpers.entity_component import EntityComponent, async_update_entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -90,7 +92,7 @@ async def test_setup_recovers_when_setup_raises(hass: HomeAssistant) -> None: ) @patch("homeassistant.setup.async_setup_component", return_value=True) async def test_setup_does_discovery( - mock_setup_component, mock_setup, hass: HomeAssistant + mock_setup_component: AsyncMock, mock_setup: AsyncMock, hass: HomeAssistant ) -> None: """Test setup for discovery.""" component = EntityComponent(_LOGGER, DOMAIN, hass) @@ -108,10 +110,17 @@ async def test_setup_does_discovery( @patch("homeassistant.helpers.entity_platform.async_track_time_interval") -async def test_set_scan_interval_via_config(mock_track, hass: HomeAssistant) -> None: +async def test_set_scan_interval_via_config( + mock_track: Mock, hass: HomeAssistant +) -> None: """Test the setting of the scan interval via configuration.""" - def platform_setup(hass, config, add_entities, discovery_info=None): + def platform_setup( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: """Test the platform setup.""" add_entities([MockEntity(should_poll=True)]) @@ -131,7 +140,12 @@ async def test_set_scan_interval_via_config(mock_track, hass: HomeAssistant) -> async def test_set_entity_namespace_via_config(hass: HomeAssistant) -> None: """Test setting an entity namespace.""" - def platform_setup(hass, config, add_entities, discovery_info=None): + def platform_setup( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: """Test the platform setup.""" add_entities([MockEntity(name="beer"), MockEntity(name=None)]) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 597045f557f8..7163461ae1ce 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -23,6 +23,7 @@ from homeassistant.helpers.entity_component import ( DEFAULT_SCAN_INTERVAL, EntityComponent, ) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util from tests.common import ( @@ -81,11 +82,11 @@ async def test_polling_updates_entities_with_exception(hass: HomeAssistant) -> N update_ok = [] update_err = [] - def update_mock(): + def update_mock() -> None: """Mock normal update.""" update_ok.append(None) - def update_mock_err(): + def update_mock_err() -> None: """Mock error update.""" update_err.append(None) raise AssertionError("Fake error update") @@ -161,10 +162,17 @@ async def test_update_state_adds_entities_with_update_before_add_false( @patch("homeassistant.helpers.entity_platform.async_track_time_interval") -async def test_set_scan_interval_via_platform(mock_track, hass: HomeAssistant) -> None: +async def test_set_scan_interval_via_platform( + mock_track: Mock, hass: HomeAssistant +) -> None: """Test the setting of the scan interval via platform.""" - def platform_setup(hass, config, add_entities, discovery_info=None): + def platform_setup( + hass: HomeAssistant, + config: ConfigType, + add_entities: entity_platform.AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: """Test the platform setup.""" add_entities([MockEntity(should_poll=True)]) @@ -192,7 +200,7 @@ async def test_adding_entities_with_generator_and_thread_callback( """ component = EntityComponent(_LOGGER, DOMAIN, hass) - def create_entity(number): + def create_entity(number: int) -> MockEntity: """Create entity helper.""" entity = MockEntity(unique_id=f"unique{number}") entity.entity_id = async_generate_entity_id(DOMAIN + ".{}", "Number", hass=hass) @@ -402,7 +410,7 @@ async def test_raise_error_on_update(hass: HomeAssistant) -> None: entity1 = MockEntity(name="test_1") entity2 = MockEntity(name="test_2") - def _raise(): + def _raise() -> None: """Raise an exception.""" raise AssertionError @@ -1490,7 +1498,10 @@ class SlowEntity(MockEntity): ), ) async def test_entity_name_influences_entity_id( - hass: HomeAssistant, has_entity_name, entity_name, expected_entity_id + hass: HomeAssistant, + has_entity_name: bool, + entity_name: str | None, + expected_entity_id: str, ) -> None: """Test entity_id is influenced by entity name.""" registry = er.async_get(hass) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 066460c90d88..d9ad81561cce 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -10,6 +10,7 @@ from astral import LocationInfo import astral.sun import async_timeout from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import jinja2 import pytest @@ -1406,7 +1407,7 @@ async def test_track_template_result_super_template_initially_false( ], ) async def test_track_template_result_super_template_2( - hass: HomeAssistant, availability_template + hass: HomeAssistant, availability_template: str ) -> None: """Test tracking template with super template listening to different entities.""" specific_runs = [] @@ -1545,7 +1546,7 @@ async def test_track_template_result_super_template_2( ], ) async def test_track_template_result_super_template_2_initially_false( - hass: HomeAssistant, availability_template + hass: HomeAssistant, availability_template: str ) -> None: """Test tracking template with super template listening to different entities.""" specific_runs = [] @@ -3898,7 +3899,9 @@ async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: # DST starts early morning March 28th 2021 @pytest.mark.freeze_time("2021-03-28 01:28:00+01:00") -async def test_periodic_task_entering_dst(hass: HomeAssistant, freezer) -> None: +async def test_periodic_task_entering_dst( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic task behavior when entering dst.""" hass.config.set_time_zone("Europe/Vienna") specific_runs = [] @@ -3944,7 +3947,9 @@ async def test_periodic_task_entering_dst(hass: HomeAssistant, freezer) -> None: # DST starts early morning March 28th 2021 @pytest.mark.freeze_time("2021-03-28 01:59:59+01:00") -async def test_periodic_task_entering_dst_2(hass: HomeAssistant, freezer) -> None: +async def test_periodic_task_entering_dst_2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic task behavior when entering dst. This tests a task firing every second in the range 0..58 (not *:*:59) @@ -3995,7 +4000,9 @@ async def test_periodic_task_entering_dst_2(hass: HomeAssistant, freezer) -> Non # DST ends early morning October 31st 2021 @pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") -async def test_periodic_task_leaving_dst(hass: HomeAssistant, freezer) -> None: +async def test_periodic_task_leaving_dst( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic task behavior when leaving dst.""" hass.config.set_time_zone("Europe/Vienna") specific_runs = [] @@ -4069,7 +4076,9 @@ async def test_periodic_task_leaving_dst(hass: HomeAssistant, freezer) -> None: # DST ends early morning October 31st 2021 @pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") -async def test_periodic_task_leaving_dst_2(hass: HomeAssistant, freezer) -> None: +async def test_periodic_task_leaving_dst_2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic task behavior when leaving dst.""" hass.config.set_time_zone("Europe/Vienna") specific_runs = [] From 273d794f7ade9f12a195d2de24586e47c6ee98c4 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:12:05 +0100 Subject: [PATCH 0527/1058] Add device class for Landis+Gyr GJ energy sensor (#89522) --- .../components/landisgyr_heat_meter/sensor.py | 37 +------------------ .../landisgyr_heat_meter/test_sensor.py | 32 ++++++++-------- 2 files changed, 18 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 8ded9e4d7258..508ae43b8e3b 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -32,20 +32,11 @@ from homeassistant.helpers.update_coordinator import ( from homeassistant.util import dt as dt_util from . import DOMAIN -from .const import GJ_TO_MWH _LOGGER = logging.getLogger(__name__) HEAT_METER_SENSOR_TYPES = ( - SensorEntityDescription( - key="heat_usage", - icon="mdi:fire", - name="Heat usage", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - ), SensorEntityDescription( key="volume_usage_m3", icon="mdi:fire", @@ -54,23 +45,14 @@ HEAT_METER_SENSOR_TYPES = ( native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL, ), - # Diagnostic entity for debugging, this will match the value in GJ indicated on the meter's display SensorEntityDescription( key="heat_usage_gj", icon="mdi:fire", name="Heat usage GJ", - native_unit_of_measurement="GJ", - entity_category=EntityCategory.DIAGNOSTIC, - ), - SensorEntityDescription( - key="heat_previous_year", - icon="mdi:fire", - name="Heat usage previous year", - native_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, device_class=SensorDeviceClass.ENERGY, - entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, ), - # Diagnostic entity for debugging, this will match the value in GJ of previous year indicated on the meter's display SensorEntityDescription( key="heat_previous_year_gj", icon="mdi:fire", @@ -293,19 +275,4 @@ class HeatMeterSensor( else: self._attr_native_value = asdict(self.coordinator.data)[self.key] - if self.key == "heat_usage": - self._attr_native_value = convert_gj_to_mwh( - self.coordinator.data.heat_usage_gj - ) - - if self.key == "heat_previous_year": - self._attr_native_value = convert_gj_to_mwh( - self.coordinator.data.heat_previous_year_gj - ) - self.async_write_ha_state() - - -def convert_gj_to_mwh(gigajoule) -> float: - """Convert GJ to MWh using the conversion value.""" - return round(gigajoule * GJ_TO_MWH, 5) diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 854ead82b3db..6296fadd1163 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -83,18 +83,18 @@ async def test_create_sensors( await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage"}, + {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage_gj"}, blocking=True, ) await hass.async_block_till_done() # check if 26 attributes have been created - assert len(hass.states.async_all()) == 27 + assert len(hass.states.async_all()) == 25 - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state - assert state.state == "34.16669" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.MEGA_WATT_HOUR + assert state.state == "123.0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.GIGA_JOULE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -132,17 +132,17 @@ async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: [ ( State( - "sensor.heat_meter_heat_usage", + "sensor.heat_meter_heat_usage_gj", "34167", attributes={ ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.MEGA_WATT_HOUR, + ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.GIGA_JOULE, ATTR_STATE_CLASS: SensorStateClass.TOTAL, }, ), { "native_value": 34167, - "native_unit_of_measurement": UnitOfEnergy.MEGA_WATT_HOUR, + "native_unit_of_measurement": UnitOfEnergy.GIGA_JOULE, "icon": "mdi:fire", "last_reset": last_reset, }, @@ -194,10 +194,10 @@ async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: await hass.async_block_till_done() # restore from cache - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state assert state.state == "34167" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.MEGA_WATT_HOUR + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.GIGA_JOULE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL state = hass.states.get("sensor.heat_meter_volume_usage") @@ -240,21 +240,21 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non await hass.services.async_call( HA_DOMAIN, SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage"}, + {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage_gj"}, blocking=True, ) await hass.async_block_till_done() # check if initial setup succeeded - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state - assert state.state == "34.16669" + assert state.state == "123.0" # Now 'disable' the connection and wait for polling and see if it fails mock_heat_meter().read.side_effect = serial.serialutil.SerialException async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) await hass.async_block_till_done() - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state.state == STATE_UNAVAILABLE # Now 'enable' and see if next poll succeeds @@ -270,6 +270,6 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non mock_heat_meter().read.side_effect = None async_fire_time_changed(hass, dt_util.utcnow() + POLLING_INTERVAL) await hass.async_block_till_done() - state = hass.states.get("sensor.heat_meter_heat_usage") + state = hass.states.get("sensor.heat_meter_heat_usage_gj") assert state - assert state.state == "34.44447" + assert state.state == "124.0" From f55aaf7664dbb43cb7c20a72753033d634c581d6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Mar 2023 11:15:38 +0100 Subject: [PATCH 0528/1058] Drop unused Google entity settings from cloud (#89786) --- homeassistant/components/cloud/const.py | 2 -- homeassistant/components/cloud/http_api.py | 2 -- homeassistant/components/cloud/prefs.py | 6 ------ tests/components/cloud/test_http_api.py | 6 ------ 4 files changed, 16 deletions(-) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 9fb4ffc7047e..9d5ed2ca28e0 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -12,9 +12,7 @@ PREF_GOOGLE_ENTITY_CONFIGS = "google_entity_configs" PREF_GOOGLE_REPORT_STATE = "google_report_state" PREF_ALEXA_ENTITY_CONFIGS = "alexa_entity_configs" PREF_ALEXA_REPORT_STATE = "alexa_report_state" -PREF_OVERRIDE_NAME = "override_name" PREF_DISABLE_2FA = "disable_2fa" -PREF_ALIASES = "aliases" PREF_SHOULD_EXPOSE = "should_expose" PREF_GOOGLE_LOCAL_WEBHOOK_ID = "google_local_webhook_id" PREF_USERNAME = "username" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index ea1a0aa27e6b..6c4115ae28a7 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -559,8 +559,6 @@ async def google_assistant_list( "type": "cloud/google_assistant/entities/update", "entity_id": str, vol.Optional("should_expose"): vol.Any(None, bool), - vol.Optional("override_name"): str, - vol.Optional("aliases"): [str], vol.Optional("disable_2fa"): bool, } ) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 17ec00026bcf..7f27e7cf39ba 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -18,7 +18,6 @@ from .const import ( PREF_ALEXA_DEFAULT_EXPOSE, PREF_ALEXA_ENTITY_CONFIGS, PREF_ALEXA_REPORT_STATE, - PREF_ALIASES, PREF_CLOUD_USER, PREF_CLOUDHOOKS, PREF_DISABLE_2FA, @@ -30,7 +29,6 @@ from .const import ( PREF_GOOGLE_LOCAL_WEBHOOK_ID, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, - PREF_OVERRIDE_NAME, PREF_REMOTE_DOMAIN, PREF_SHOULD_EXPOSE, PREF_TTS_DEFAULT_VOICE, @@ -118,9 +116,7 @@ class CloudPreferences: self, *, entity_id, - override_name=UNDEFINED, disable_2fa=UNDEFINED, - aliases=UNDEFINED, should_expose=UNDEFINED, ): """Update config for a Google entity.""" @@ -129,9 +125,7 @@ class CloudPreferences: changes = {} for key, value in ( - (PREF_OVERRIDE_NAME, override_name), (PREF_DISABLE_2FA, disable_2fa), - (PREF_ALIASES, aliases), (PREF_SHOULD_EXPOSE, should_expose), ): if value is not UNDEFINED: diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index c6222314a7f5..92c0ca70a17a 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -727,8 +727,6 @@ async def test_update_google_entity( "type": "cloud/google_assistant/entities/update", "entity_id": "light.kitchen", "should_expose": False, - "override_name": "updated name", - "aliases": ["lefty", "righty"], "disable_2fa": False, } ) @@ -738,8 +736,6 @@ async def test_update_google_entity( prefs = hass.data[DOMAIN].client.prefs assert prefs.google_entity_configs["light.kitchen"] == { "should_expose": False, - "override_name": "updated name", - "aliases": ["lefty", "righty"], "disable_2fa": False, } @@ -757,8 +753,6 @@ async def test_update_google_entity( prefs = hass.data[DOMAIN].client.prefs assert prefs.google_entity_configs["light.kitchen"] == { "should_expose": None, - "override_name": "updated name", - "aliases": ["lefty", "righty"], "disable_2fa": False, } From f32b7859b8545e93aed801e69b35b2d078c0da1c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 12:16:08 +0100 Subject: [PATCH 0529/1058] Restructure translations for entity components (#89702) --- .../alarm_control_panel/strings.json | 26 +-- homeassistant/components/alert/strings.json | 10 +- .../components/automation/strings.json | 8 +- .../components/binary_sensor/strings.json | 168 ++++++++++++------ .../components/calendar/strings.json | 8 +- homeassistant/components/camera/strings.json | 10 +- homeassistant/components/climate/strings.json | 168 +++++++++--------- .../components/configurator/strings.json | 8 +- homeassistant/components/cover/strings.json | 14 +- .../components/device_tracker/strings.json | 8 +- homeassistant/components/fan/strings.json | 8 +- homeassistant/components/group/strings.json | 24 +-- .../components/humidifier/strings.json | 8 +- .../components/input_boolean/strings.json | 8 +- homeassistant/components/light/strings.json | 8 +- homeassistant/components/lock/strings.json | 8 +- .../components/media_player/strings.json | 18 +- homeassistant/components/person/strings.json | 8 +- homeassistant/components/plant/strings.json | 8 +- homeassistant/components/remote/strings.json | 8 +- .../components/schedule/strings.json | 8 +- homeassistant/components/script/strings.json | 8 +- homeassistant/components/sensor/strings.json | 8 +- homeassistant/components/sun/strings.json | 8 +- homeassistant/components/switch/strings.json | 8 +- homeassistant/components/timer/strings.json | 10 +- homeassistant/components/vacuum/strings.json | 20 ++- .../components/water_heater/strings.json | 18 +- homeassistant/components/weather/strings.json | 34 ++-- homeassistant/helpers/translation.py | 17 +- script/hassfest/translations.py | 65 +++---- tests/helpers/test_translation.py | 18 +- 32 files changed, 435 insertions(+), 321 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 5126f49d92b8..1d8a29f7dd8f 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -26,19 +26,21 @@ "armed_vacation": "{entity_name} armed vacation" } }, - "state": { + "entity_component": { "_": { - "armed": "Armed", - "disarmed": "Disarmed", - "armed_home": "Armed home", - "armed_away": "Armed away", - "armed_night": "Armed night", - "armed_vacation": "Armed vacation", - "armed_custom_bypass": "Armed custom bypass", - "pending": "Pending", - "arming": "Arming", - "disarming": "Disarming", - "triggered": "Triggered" + "state": { + "armed": "Armed", + "disarmed": "Disarmed", + "armed_home": "Armed home", + "armed_away": "Armed away", + "armed_night": "Armed night", + "armed_vacation": "Armed vacation", + "armed_custom_bypass": "Armed custom bypass", + "pending": "Pending", + "arming": "Arming", + "disarming": "Disarming", + "triggered": "Triggered" + } } } } diff --git a/homeassistant/components/alert/strings.json b/homeassistant/components/alert/strings.json index fb31ecd0577d..9975e6ee0df9 100644 --- a/homeassistant/components/alert/strings.json +++ b/homeassistant/components/alert/strings.json @@ -1,10 +1,12 @@ { "title": "Alert", - "state": { + "entity_component": { "_": { - "idle": "[%key:common::state::idle%]", - "off": "Acknowledged", - "on": "[%key:common::state::active%]" + "state": { + "idle": "[%key:common::state::idle%]", + "off": "Acknowledged", + "on": "[%key:common::state::active%]" + } } } } diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index ea03868e6399..ffadc47a48cd 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -1,9 +1,11 @@ { "title": "Automation", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } }, "issues": { diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 5d17fb92cb13..8d787204dba7 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -106,114 +106,168 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { + "_": { + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, "battery": { - "off": "Normal", - "on": "Low" + "state": { + "off": "Normal", + "on": "Low" + } }, "battery_charging": { - "off": "Not charging", - "on": "Charging" + "state": { + "off": "Not charging", + "on": "Charging" + } }, "carbon_monoxide": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "cold": { - "off": "[%key:component::binary_sensor::state::battery::off%]", - "on": "Cold" + "state": { + "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "on": "Cold" + } }, "connectivity": { - "off": "[%key:common::state::disconnected%]", - "on": "[%key:common::state::connected%]" + "state": { + "off": "[%key:common::state::disconnected%]", + "on": "[%key:common::state::connected%]" + } }, "door": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } }, "garage_door": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } }, "gas": { - "off": "Clear", - "on": "Detected" + "state": { + "off": "Clear", + "on": "Detected" + } }, "heat": { - "off": "[%key:component::binary_sensor::state::battery::off%]", - "on": "Hot" + "state": { + "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", + "on": "Hot" + } }, "light": { - "off": "No light", - "on": "Light detected" + "state": { + "off": "No light", + "on": "Light detected" + } }, "lock": { - "off": "[%key:common::state::locked%]", - "on": "[%key:common::state::unlocked%]" + "state": { + "off": "[%key:common::state::locked%]", + "on": "[%key:common::state::unlocked%]" + } }, "moisture": { - "off": "Dry", - "on": "Wet" + "state": { + "off": "Dry", + "on": "Wet" + } }, "motion": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "moving": { - "off": "Not moving", - "on": "Moving" + "state": { + "off": "Not moving", + "on": "Moving" + } }, "occupancy": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "opening": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } }, "plug": { - "off": "Unplugged", - "on": "Plugged in" + "state": { + "off": "Unplugged", + "on": "Plugged in" + } }, "presence": { - "off": "[%key:component::device_tracker::state::_::not_home%]", - "on": "[%key:component::device_tracker::state::_::home%]" + "state": { + "off": "[%key:component::device_tracker::entity_component::_::state::not_home%]", + "on": "[%key:component::device_tracker::entity_component::_::state::home%]" + } }, "problem": { - "off": "OK", - "on": "Problem" + "state": { + "off": "OK", + "on": "Problem" + } }, "running": { - "off": "Not running", - "on": "Running" + "state": { + "off": "Not running", + "on": "Running" + } }, "safety": { - "off": "Safe", - "on": "Unsafe" + "state": { + "off": "Safe", + "on": "Unsafe" + } }, "smoke": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "sound": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "update": { - "off": "Up-to-date", - "on": "Update available" + "state": { + "off": "Up-to-date", + "on": "Update available" + } }, "vibration": { - "off": "[%key:component::binary_sensor::state::gas::off%]", - "on": "[%key:component::binary_sensor::state::gas::on%]" + "state": { + "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + } }, "window": { - "off": "[%key:common::state::closed%]", - "on": "[%key:common::state::open%]" - }, - "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::closed%]", + "on": "[%key:common::state::open%]" + } } }, "device_class": { diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 3af9a78e6071..bcc07bb7fccd 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -1,9 +1,11 @@ { "title": "Calendar", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 3b8767ec8cd7..5bde2ed25173 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -1,10 +1,12 @@ { "title": "Camera", - "state": { + "entity_component": { "_": { - "recording": "Recording", - "streaming": "Streaming", - "idle": "[%key:common::state::idle%]" + "state": { + "recording": "Recording", + "streaming": "Streaming", + "idle": "[%key:common::state::idle%]" + } } } } diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 8c6c8f2d97a5..16cf9a130bb2 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -15,92 +15,92 @@ "set_preset_mode": "Change preset on {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "heat": "Heat", - "cool": "Cool", - "heat_cool": "Heat/Cool", - "auto": "Auto", - "dry": "Dry", - "fan_only": "Fan only" - } - }, - "state_attributes": { - "_": { - "aux_heat": { "name": "Aux heat" }, - "current_humidity": { "name": "Current humidity" }, - "current_temperature": { "name": "Current temperature" }, - "fan_mode": { - "name": "Fan mode", - "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "auto": "Auto", - "low": "Low", - "medium": "Medium", - "high": "High", - "top": "Top", - "middle": "Middle", - "focus": "Focus", - "diffuse": "Diffuse" - } + "state": { + "off": "[%key:common::state::off%]", + "heat": "Heat", + "cool": "Cool", + "heat_cool": "Heat/Cool", + "auto": "Auto", + "dry": "Dry", + "fan_only": "Fan only" }, - "fan_modes": { - "name": "Fan modes" - }, - "humidity": { "name": "Target humidity" }, - "hvac_action": { - "name": "Current action", - "state": { - "off": "Off", - "heating": "Heating", - "cooling": "Cooling", - "drying": "Drying", - "idle": "Idle", - "fan": "Fan" - } - }, - "hvac_modes": { - "name": "HVAC modes" - }, - "max_humidity": { "name": "Max target humidity" }, - "max_temp": { "name": "Max target temperature" }, - "min_humidity": { "name": "Min target humidity" }, - "min_temp": { "name": "Min target temperature" }, - "preset_mode": { - "name": "Preset", - "state": { - "none": "None", - "eco": "Eco", - "away": "Away", - "boost": "Boost", - "comfort": "Comfort", - "home": "Home", - "sleep": "Sleep", - "activity": "Activity" - } - }, - "preset_modes": { - "name": "Presets" - }, - "swing_mode": { - "name": "Swing mode", - "state": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "both": "Both", - "vertical": "Vertical", - "horizontal": "Horizontal" - } - }, - "swing_modes": { - "name": "Swing modes" - }, - "target_temp_high": { "name": "Upper target temperature" }, - "target_temp_low": { "name": "Lower target temperature" }, - "target_temp_step": { "name": "Target temperature step" }, - "temperature": { "name": "Target temperature" } + "state_attributes": { + "aux_heat": { "name": "Aux heat" }, + "current_humidity": { "name": "Current humidity" }, + "current_temperature": { "name": "Current temperature" }, + "fan_mode": { + "name": "Fan mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "auto": "Auto", + "low": "Low", + "medium": "Medium", + "high": "High", + "top": "Top", + "middle": "Middle", + "focus": "Focus", + "diffuse": "Diffuse" + } + }, + "fan_modes": { + "name": "Fan modes" + }, + "humidity": { "name": "Target humidity" }, + "hvac_action": { + "name": "Current action", + "state": { + "off": "Off", + "heating": "Heating", + "cooling": "Cooling", + "drying": "Drying", + "idle": "Idle", + "fan": "Fan" + } + }, + "hvac_modes": { + "name": "HVAC modes" + }, + "max_humidity": { "name": "Max target humidity" }, + "max_temp": { "name": "Max target temperature" }, + "min_humidity": { "name": "Min target humidity" }, + "min_temp": { "name": "Min target temperature" }, + "preset_mode": { + "name": "Preset", + "state": { + "none": "None", + "eco": "Eco", + "away": "Away", + "boost": "Boost", + "comfort": "Comfort", + "home": "Home", + "sleep": "Sleep", + "activity": "Activity" + } + }, + "preset_modes": { + "name": "Presets" + }, + "swing_mode": { + "name": "Swing mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "both": "Both", + "vertical": "Vertical", + "horizontal": "Horizontal" + } + }, + "swing_modes": { + "name": "Swing modes" + }, + "target_temp_high": { "name": "Upper target temperature" }, + "target_temp_low": { "name": "Lower target temperature" }, + "target_temp_step": { "name": "Target temperature step" }, + "temperature": { "name": "Target temperature" } + } } } } diff --git a/homeassistant/components/configurator/strings.json b/homeassistant/components/configurator/strings.json index 570c18d3cde1..c48f1d838581 100644 --- a/homeassistant/components/configurator/strings.json +++ b/homeassistant/components/configurator/strings.json @@ -1,9 +1,11 @@ { "title": "Configurator", - "state": { + "entity_component": { "_": { - "configure": "Configure", - "configured": "Configured" + "state": { + "configure": "Configure", + "configured": "Configured" + } } } } diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index cb98c542d431..d17508b71db2 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -27,13 +27,15 @@ "tilt_position": "{entity_name} tilt position changes" } }, - "state": { + "entity_component": { "_": { - "open": "[%key:common::state::open%]", - "opening": "Opening", - "closed": "[%key:common::state::closed%]", - "closing": "Closing", - "stopped": "Stopped" + "state": { + "open": "[%key:common::state::open%]", + "opening": "Opening", + "closed": "[%key:common::state::closed%]", + "closing": "Closing", + "stopped": "Stopped" + } } } } diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 48cb667e7308..a1c50c88f861 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -10,10 +10,12 @@ "leaves": "{entity_name} leaves a zone" } }, - "state": { + "entity_component": { "_": { - "home": "[%key:common::state::home%]", - "not_home": "[%key:common::state::not_home%]" + "state": { + "home": "[%key:common::state::home%]", + "not_home": "[%key:common::state::not_home%]" + } } } } diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index fdd95a822de1..670d11b76baa 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -16,10 +16,12 @@ "turn_off": "Turn off {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 75a2423d9327..17f63167dbe6 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -155,18 +155,20 @@ } } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "home": "[%key:component::device_tracker::state::_::home%]", - "not_home": "[%key:component::device_tracker::state::_::not_home%]", - "open": "[%key:common::state::open%]", - "closed": "[%key:common::state::closed%]", - "locked": "[%key:common::state::locked%]", - "unlocked": "[%key:common::state::unlocked%]", - "ok": "[%key:component::binary_sensor::state::problem::off%]", - "problem": "[%key:component::binary_sensor::state::problem::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "home": "[%key:component::device_tracker::entity_component::_::state::home%]", + "not_home": "[%key:component::device_tracker::entity_component::_::state::not_home%]", + "open": "[%key:common::state::open%]", + "closed": "[%key:common::state::closed%]", + "locked": "[%key:common::state::locked%]", + "unlocked": "[%key:common::state::unlocked%]", + "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", + "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + } } } } diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 46e2fc160554..e536def16771 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -20,10 +20,12 @@ "turn_off": "Turn off {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index a32958592f2a..509799b5ed34 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -1,9 +1,11 @@ { "title": "Input boolean", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index ee1f8e13b614..38f843ab1dc4 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -19,10 +19,12 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index 9e4c4ea726a0..ab7a1632ea7a 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -15,10 +15,12 @@ "unlocked": "{entity_name} unlocked" } }, - "state": { + "entity_component": { "_": { - "locked": "[%key:common::state::locked%]", - "unlocked": "[%key:common::state::unlocked%]" + "state": { + "locked": "[%key:common::state::locked%]", + "unlocked": "[%key:common::state::unlocked%]" + } } } } diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index bb6c7d16f5a0..2c8f3d3d2bd8 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -19,15 +19,17 @@ "changed_states": "{entity_name} changed states" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "playing": "Playing", - "paused": "[%key:common::state::paused%]", - "idle": "[%key:common::state::idle%]", - "standby": "[%key:common::state::standby%]", - "buffering": "Buffering" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "playing": "Playing", + "paused": "[%key:common::state::paused%]", + "idle": "[%key:common::state::idle%]", + "standby": "[%key:common::state::standby%]", + "buffering": "Buffering" + } } } } diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index c94499d92f5c..7bba0198a141 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -1,9 +1,11 @@ { "title": "Person", - "state": { + "entity_component": { "_": { - "home": "[%key:common::state::home%]", - "not_home": "[%key:common::state::not_home%]" + "state": { + "home": "[%key:common::state::home%]", + "not_home": "[%key:common::state::not_home%]" + } } } } diff --git a/homeassistant/components/plant/strings.json b/homeassistant/components/plant/strings.json index 2478564ca88e..5ece766c71ac 100644 --- a/homeassistant/components/plant/strings.json +++ b/homeassistant/components/plant/strings.json @@ -1,9 +1,11 @@ { "title": "Plant Monitor", - "state": { + "entity_component": { "_": { - "ok": "[%key:component::binary_sensor::state::problem::off%]", - "problem": "[%key:component::binary_sensor::state::problem::on%]" + "state": { + "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", + "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + } } } } diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index 4a2b20c65de4..a558cc76fe07 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -16,10 +16,12 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index fdcb8c4ffdc8..ecc673805c20 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -1,9 +1,11 @@ { "title": "Schedule", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index 2d39b6ac6332..a4ea0860067e 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -1,9 +1,11 @@ { "title": "Script", - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 2396bbf295b8..4b764c609c09 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -94,10 +94,12 @@ "wind_speed": "{entity_name} wind speed changes" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index cdcaa416eda1..d8a75224f62a 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -10,10 +10,12 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, - "state": { + "entity_component": { "_": { - "above_horizon": "Above horizon", - "below_horizon": "Below horizon" + "state": { + "above_horizon": "Above horizon", + "below_horizon": "Below horizon" + } } } } diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 7ea84e649ef3..ba7c5c6848f5 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -16,10 +16,12 @@ "turned_off": "{entity_name} turned off" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]" + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } } } } diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 985cea0aa6ef..914ee738354f 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,9 +1,11 @@ { - "state": { + "entity_component": { "_": { - "active": "[%key:common::state::active%]", - "idle": "[%key:common::state::idle%]", - "paused": "[%key:common::state::paused%]" + "state": { + "active": "[%key:common::state::active%]", + "idle": "[%key:common::state::idle%]", + "paused": "[%key:common::state::paused%]" + } } } } diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 033946735f77..eb84b910b454 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -14,16 +14,18 @@ "dock": "Let {entity_name} return to the dock" } }, - "state": { + "entity_component": { "_": { - "cleaning": "Cleaning", - "docked": "Docked", - "error": "Error", - "idle": "[%key:common::state::idle%]", - "off": "[%key:common::state::off%]", - "on": "[%key:common::state::on%]", - "paused": "[%key:common::state::paused%]", - "returning": "Returning to dock" + "state": { + "cleaning": "Cleaning", + "docked": "Docked", + "error": "Error", + "idle": "[%key:common::state::idle%]", + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "paused": "[%key:common::state::paused%]", + "returning": "Returning to dock" + } } } } diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 3d9ab67eab4c..9e3eec86041a 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -5,15 +5,17 @@ "turn_off": "Turn off {entity_name}" } }, - "state": { + "entity_component": { "_": { - "off": "[%key:common::state::off%]", - "eco": "Eco", - "electric": "Electric", - "gas": "Gas", - "high_demand": "High Demand", - "heat_pump": "Heat Pump", - "performance": "Performance" + "state": { + "off": "[%key:common::state::off%]", + "eco": "Eco", + "electric": "Electric", + "gas": "Gas", + "high_demand": "High Demand", + "heat_pump": "Heat Pump", + "performance": "Performance" + } } } } diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index c4764beb5b6e..b3af53a91c4a 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -1,21 +1,23 @@ { - "state": { + "entity_component": { "_": { - "clear-night": "Clear, night", - "cloudy": "Cloudy", - "exceptional": "Exceptional", - "fog": "Fog", - "hail": "Hail", - "lightning": "Lightning", - "lightning-rainy": "Lightning, rainy", - "partlycloudy": "Partly cloudy", - "pouring": "Pouring", - "rainy": "Rainy", - "snowy": "Snowy", - "snowy-rainy": "Snowy, rainy", - "sunny": "Sunny", - "windy": "Windy", - "windy-variant": "Windy" + "state": { + "clear-night": "Clear, night", + "cloudy": "Cloudy", + "exceptional": "Exceptional", + "fog": "Fog", + "hail": "Hail", + "lightning": "Lightning", + "lightning-rainy": "Lightning, rainy", + "partlycloudy": "Partly cloudy", + "pouring": "Pouring", + "rainy": "Rainy", + "snowy": "Snowy", + "snowy-rainy": "Snowy, rainy", + "sunny": "Sunny", + "windy": "Windy", + "windy-variant": "Windy" + } } } } diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 7ccbea3653da..96ce9b618c2d 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -255,13 +255,16 @@ class _TranslationCache: categories.update(resource) for category in categories: - resource_func = ( - _merge_resources if category == "state" else _build_resources - ) new_resources: Mapping[str, dict[str, Any] | str] - new_resources = resource_func( # type: ignore[assignment] - translation_strings, components, category - ) + + if category in ("state", "entity_component"): + new_resources = _merge_resources( + translation_strings, components, category + ) + else: + new_resources = _build_resources( + translation_strings, components, category + ) for component, resource in new_resources.items(): category_cache: dict[str, Any] = cached.setdefault( @@ -299,7 +302,7 @@ async def async_get_translations( components = set(integrations) elif config_flow: components = (await async_get_config_flows(hass)) - hass.config.components - elif category == "state": + elif category in ("state", "entity_component"): components = set(hass.config.components) else: # Only 'state' supports merging, so remove platforms from selection diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 92a1047c304b..eb51d12c3745 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -231,25 +231,6 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: vol.Optional("trigger_type"): {str: cv.string_with_no_html}, vol.Optional("trigger_subtype"): {str: cv.string_with_no_html}, }, - vol.Optional("state"): cv.schema_with_slug_keys( - cv.schema_with_slug_keys( - cv.string_with_no_html, slug_validator=translation_key_validator - ), - slug_validator=vol.Any("_", cv.slug), - ), - vol.Optional("state_attributes"): cv.schema_with_slug_keys( - cv.schema_with_slug_keys( - { - vol.Optional("name"): str, - vol.Optional("state"): cv.schema_with_slug_keys( - cv.string_with_no_html, - slug_validator=translation_key_validator, - ), - }, - slug_validator=translation_key_validator, - ), - slug_validator=vol.Any("_", cv.slug), - ), vol.Optional("system_health"): { vol.Optional("info"): cv.schema_with_slug_keys( cv.string_with_no_html, slug_validator=translation_key_validator @@ -283,26 +264,48 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: ), ) }, - vol.Optional("entity"): { - str: { - str: { + vol.Optional("entity_component"): cv.schema_with_slug_keys( + { + vol.Optional("state"): cv.schema_with_slug_keys( + cv.string_with_no_html, + slug_validator=translation_key_validator, + ), + vol.Optional("state_attributes"): cv.schema_with_slug_keys( + { + vol.Optional("name"): str, + vol.Optional("state"): cv.schema_with_slug_keys( + cv.string_with_no_html, + slug_validator=translation_key_validator, + ), + }, + slug_validator=translation_key_validator, + ), + }, + slug_validator=vol.Any("_", cv.slug), + ), + vol.Optional("entity"): cv.schema_with_slug_keys( + cv.schema_with_slug_keys( + { vol.Optional("name"): cv.string_with_no_html, - vol.Optional("state_attributes"): { - str: { + vol.Optional("state"): cv.schema_with_slug_keys( + cv.string_with_no_html, + slug_validator=translation_key_validator, + ), + vol.Optional("state_attributes"): cv.schema_with_slug_keys( + { vol.Optional("name"): cv.string_with_no_html, vol.Optional("state"): cv.schema_with_slug_keys( cv.string_with_no_html, slug_validator=translation_key_validator, ), - } - }, - vol.Optional("state"): cv.schema_with_slug_keys( - cv.string_with_no_html, + }, slug_validator=translation_key_validator, ), - } - } - }, + }, + slug_validator=translation_key_validator, + ), + slug_validator=cv.slug, + ), } ) diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 25f1a7426643..197053ba2b8d 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -373,32 +373,32 @@ async def test_caching(hass: HomeAssistant) -> None: "homeassistant.helpers.translation._merge_resources", side_effect=translation._merge_resources, ) as mock_merge: - load1 = await translation.async_get_translations(hass, "en", "state") + load1 = await translation.async_get_translations(hass, "en", "entity_component") assert len(mock_merge.mock_calls) == 1 - load2 = await translation.async_get_translations(hass, "en", "state") + load2 = await translation.async_get_translations(hass, "en", "entity_component") assert len(mock_merge.mock_calls) == 1 assert load1 == load2 for key in load1: - assert key.startswith("component.sensor.state.") or key.startswith( - "component.light.state." - ) + assert key.startswith( + "component.sensor.entity_component._.state." + ) or key.startswith("component.light.entity_component._.state.") load_sensor_only = await translation.async_get_translations( - hass, "en", "state", integrations={"sensor"} + hass, "en", "entity_component", integrations={"sensor"} ) assert load_sensor_only for key in load_sensor_only: - assert key.startswith("component.sensor.state.") + assert key.startswith("component.sensor.entity_component._.state.") load_light_only = await translation.async_get_translations( - hass, "en", "state", integrations={"light"} + hass, "en", "entity_component", integrations={"light"} ) assert load_light_only for key in load_light_only: - assert key.startswith("component.light.state.") + assert key.startswith("component.light.entity_component._.state.") hass.config.components.add("media_player") From e57031b1b5bb2bd23d712d98450277daefd0ab4d Mon Sep 17 00:00:00 2001 From: Kevin Siml Date: Thu, 16 Mar 2023 13:03:05 +0100 Subject: [PATCH 0530/1058] Add Pushsafer notify parameters (#89555) --- homeassistant/components/pushsafer/notify.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py index ddf4ca5ef4bf..5411db05e2d4 100644 --- a/homeassistant/components/pushsafer/notify.py +++ b/homeassistant/components/pushsafer/notify.py @@ -40,7 +40,10 @@ ATTR_TIME2LIVE = "time2live" ATTR_PRIORITY = "priority" ATTR_RETRY = "retry" ATTR_EXPIRE = "expire" +ATTR_CONFIRM = "confirm" ATTR_ANSWER = "answer" +ATTR_ANSWEROPTIONS = "answeroptions" +ATTR_ANSWERFORCE = "answerforce" ATTR_PICTURE1 = "picture1" # Attributes contained in picture1 @@ -120,7 +123,10 @@ class PushsaferNotificationService(BaseNotificationService): "pr": data.get(ATTR_PRIORITY, ""), "re": data.get(ATTR_RETRY, ""), "ex": data.get(ATTR_EXPIRE, ""), + "cr": data.get(ATTR_CONFIRM, ""), "a": data.get(ATTR_ANSWER, ""), + "ao": data.get(ATTR_ANSWEROPTIONS, ""), + "af": data.get(ATTR_ANSWERFORCE, ""), "p": picture1_encoded, } From b3bd80d905b32c2a20ab5e6ab2d1a2483123bec1 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 16 Mar 2023 13:26:56 +0100 Subject: [PATCH 0531/1058] Handle int or mapping for off case in nibe cooling (#89680) Handle int or mapping for off case in nibe --- homeassistant/components/nibe_heatpump/climate.py | 10 +++++++--- homeassistant/components/nibe_heatpump/const.py | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index a68aabacf4b4..0df787de9869 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -31,6 +31,7 @@ from . import Coordinator from .const import ( DOMAIN, LOGGER, + VALUES_COOL_WITH_ROOM_SENSOR_OFF, VALUES_MIXING_VALVE_CLOSED_STATE, VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, @@ -139,10 +140,13 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): mode = HVACMode.OFF if _get_value(self._coil_use_room_sensor) == "ON": - if _get_value(self._coil_cooling_with_room_sensor) != "OFF": - mode = HVACMode.HEAT_COOL - else: + if ( + _get_value(self._coil_cooling_with_room_sensor) + in VALUES_COOL_WITH_ROOM_SENSOR_OFF + ): mode = HVACMode.HEAT + else: + mode = HVACMode.HEAT_COOL self._attr_hvac_mode = mode setpoint_heat = _get_float(self._coil_setpoint_heat) diff --git a/homeassistant/components/nibe_heatpump/const.py b/homeassistant/components/nibe_heatpump/const.py index 7d9bf58709cb..dc6b4b18996b 100644 --- a/homeassistant/components/nibe_heatpump/const.py +++ b/homeassistant/components/nibe_heatpump/const.py @@ -17,3 +17,4 @@ CONF_MODBUS_UNIT = "modbus_unit" VALUES_MIXING_VALVE_CLOSED_STATE = (30, "CLOSED", "SHUNT CLOSED") VALUES_PRIORITY_HEATING = (30, "HEAT") VALUES_PRIORITY_COOLING = (60, "COOLING") +VALUES_COOL_WITH_ROOM_SENSOR_OFF = (0, "OFF") From c6568ffb6240873cf2de81050a9e8733fbffa87a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 13:38:22 +0100 Subject: [PATCH 0532/1058] Fix lingering timer in collection helper tests (#89793) * Fix lingering timer in collection helper tests * One more --- tests/helpers/test_collection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index fe403f8db1ba..64c83757a7b2 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -256,6 +256,7 @@ async def test_storage_collection(hass: HomeAssistant) -> None: async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: """Test attaching collection to entity component.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) + await ent_comp.async_setup({}) coll = MockObservableCollection(_LOGGER) collection.sync_entity_lifecycle(hass, "test", "test", ent_comp, coll, MockEntity) @@ -295,6 +296,7 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: """Test aborted entity adding is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) + await ent_comp.async_setup({}) coll = MockObservableCollection(_LOGGER) async_update_config_calls = [] @@ -361,6 +363,7 @@ async def test_entity_component_collection_abort(hass: HomeAssistant) -> None: async def test_entity_component_collection_entity_removed(hass: HomeAssistant) -> None: """Test entity removal is handled.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) + await ent_comp.async_setup({}) coll = MockObservableCollection(_LOGGER) async_update_config_calls = [] From 886c2635ad34fa20a088b9adc955145a4c7c880a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Mar 2023 14:02:26 +0100 Subject: [PATCH 0533/1058] Add support for constant selector (#89573) * Add support for constant selector * Adapt to frontend PR changes --- homeassistant/helpers/selector.py | 32 ++++++++++++++++++ tests/helpers/test_selector.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index fe4709a30210..865ea6374e12 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -358,6 +358,38 @@ class ConfigEntrySelector(Selector[ConfigEntrySelectorConfig]): return config +class ConstantSelectorConfig(TypedDict, total=False): + """Class to represent a constant selector config.""" + + label: str + translation_key: str + value: str | int | bool + + +@SELECTORS.register("constant") +class ConstantSelector(Selector[ConstantSelectorConfig]): + """Constant selector.""" + + selector_type = "constant" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("label"): str, + vol.Optional("translation_key"): cv.string, + vol.Required("value"): vol.Any(str, int, bool), + } + ) + + def __init__(self, config: ConstantSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + vol.Schema(self.config["value"])(data) + return self.config["value"] + + class DateSelectorConfig(TypedDict): """Class to represent a date selector config.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index a5fa5c7a50d7..6f1cb2baef76 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -836,3 +836,57 @@ def test_file_selector_schema(schema, valid_selections, invalid_selections) -> N """Test file selector.""" _test_selector("file", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {"value": True, "label": "Blah"}, + (True, 1), + (None, False, 0, "abc", "def"), + ), + ( + {"value": False}, + (False, 0), + (None, True, 1, "abc", "def"), + ), + ( + {"value": 0}, + (0, False), + (None, True, 1, "abc", "def"), + ), + ( + {"value": 1}, + (1, True), + (None, False, 0, "abc", "def"), + ), + ( + {"value": 4}, + (4,), + (None, False, True, 0, 1, "abc", "def"), + ), + ( + {"value": "dog"}, + ("dog",), + (None, False, True, 0, 1, "abc", "def"), + ), + ), +) +def test_constant_selector_schema(schema, valid_selections, invalid_selections) -> None: + """Test constant selector.""" + _test_selector("constant", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + "schema", + ( + {}, # Value is mandatory + {"value": []}, # Value must be str, int or bool + {"value": 123, "label": 123}, # Label must be str + ), +) +def test_constant_selector_schema_error(schema) -> None: + """Test constant selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"constant": schema}) From c81a38effb8d3d9fd56dd8f46d65012f6974444c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 16 Mar 2023 15:57:01 +0100 Subject: [PATCH 0534/1058] Mqtt prepare test base part1 (#89796) * Refactor test_reloadable * Refactor test_disabling_and_enabling_entry * optimize test_unload_config_entry * Cleanup help_test_unload_config_entry * cleanup test_unload_entry * Update test tls_version * More tests to entry only * Add validate and hassconfig patch * Revert fixture changes patch_hass_config * Follow up comments --- tests/components/mqtt/conftest.py | 8 + .../mqtt/test_alarm_control_panel.py | 35 +- tests/components/mqtt/test_binary_sensor.py | 15 +- tests/components/mqtt/test_button.py | 16 +- tests/components/mqtt/test_camera.py | 20 +- tests/components/mqtt/test_climate.py | 16 +- tests/components/mqtt/test_common.py | 99 ++-- tests/components/mqtt/test_cover.py | 16 +- tests/components/mqtt/test_device_trigger.py | 13 +- tests/components/mqtt/test_discovery.py | 2 +- tests/components/mqtt/test_fan.py | 16 +- tests/components/mqtt/test_humidifier.py | 16 +- tests/components/mqtt/test_init.py | 422 +++++++++--------- tests/components/mqtt/test_legacy_vacuum.py | 11 +- tests/components/mqtt/test_light.py | 16 +- tests/components/mqtt/test_light_json.py | 11 +- tests/components/mqtt/test_light_template.py | 16 +- tests/components/mqtt/test_lock.py | 15 +- tests/components/mqtt/test_number.py | 16 +- tests/components/mqtt/test_scene.py | 16 +- tests/components/mqtt/test_select.py | 16 +- tests/components/mqtt/test_sensor.py | 15 +- tests/components/mqtt/test_siren.py | 16 +- tests/components/mqtt/test_state_vacuum.py | 11 +- tests/components/mqtt/test_switch.py | 16 +- tests/components/mqtt/test_tag.py | 4 +- tests/components/mqtt/test_text.py | 16 +- tests/components/mqtt/test_update.py | 16 +- 28 files changed, 389 insertions(+), 516 deletions(-) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index c88988fa6756..696ad28b7351 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -1,3 +1,11 @@ """Test fixtures for mqtt component.""" + +import pytest + from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.components.light.conftest import mock_light_profiles # noqa: F401 + + +@pytest.fixture(autouse=True) +def patch_hass_config(mock_hass_config: None) -> None: + """Patch configuration].yaml.""" diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index be0e19d35511..8056e98a3c5d 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -1,7 +1,6 @@ """The tests the MQTT alarm control panel component.""" import copy import json -from pathlib import Path from unittest.mock import patch import pytest @@ -64,11 +63,12 @@ from .test_common import ( help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, + help_test_validate_platform_config, ) from tests.common import async_fire_mqtt_message from tests.components.alarm_control_panel import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient CODE_NUMBER = "1234" CODE_TEXT = "HELLO_CODE" @@ -173,26 +173,18 @@ async def test_fail_setup_without_state_or_command_topic( ) -> None: """Test for failing setup with no state or command topic.""" assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - is valid + help_test_validate_platform_config(hass, alarm_control_panel.DOMAIN, config) + == valid ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_update_state_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test updating with via state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG, - ) + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() entity_id = "alarm_control_panel.test" @@ -1057,16 +1049,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = alarm_control_panel.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -1078,12 +1066,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = alarm_control_panel.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index ccd0e7040e06..a088d2ac6412 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -54,7 +54,7 @@ from tests.common import ( async_fire_time_changed, mock_restore_cache, ) -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -1071,16 +1071,12 @@ async def test_entity_debug_info_message( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = binary_sensor.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1187,12 +1183,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = binary_sensor.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 17e082f294d0..c4aa5a8606af 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -1,6 +1,5 @@ """The tests for the MQTT button platform.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -43,7 +42,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {button.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -525,16 +524,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = button.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -546,12 +541,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = button.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 613e522321da..dc96d3e9cdb2 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -2,7 +2,6 @@ from base64 import b64encode from http import HTTPStatus import json -from pathlib import Path from unittest.mock import patch import pytest @@ -42,7 +41,11 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import ClientSessionGenerator, MqttMockHAClientGenerator +from tests.typing import ( + ClientSessionGenerator, + MqttMockHAClientGenerator, + MqttMockPahoClient, +) DEFAULT_CONFIG = {mqtt.DOMAIN: {camera.DOMAIN: {"name": "test", "topic": "test_topic"}}} @@ -427,16 +430,12 @@ async def test_entity_debug_info_message( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = camera.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -448,12 +447,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = camera.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 4e21481c9ab1..f9f13034eaf1 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1,7 +1,6 @@ """The tests for the mqtt climate component.""" import copy import json -from pathlib import Path from unittest.mock import call, patch import pytest @@ -64,7 +63,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message from tests.components.climate import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ENTITY_CLIMATE = "climate.test" @@ -2007,16 +2006,12 @@ async def test_humidity_configuration_validity( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = climate.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -2028,12 +2023,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = climate.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 2cf8646d9237..0f54e8394986 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -8,13 +8,16 @@ from typing import Any from unittest.mock import ANY, MagicMock, patch import pytest +import voluptuous as vol import yaml -from homeassistant import config as hass_config +from homeassistant import config as module_hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info +from homeassistant.components.mqtt.config_integration import PLATFORM_CONFIG_SCHEMA_BASE from homeassistant.components.mqtt.const import MQTT_DISCONNECTED from homeassistant.components.mqtt.mixins import MQTT_ATTRIBUTES_BLOCKED +from homeassistant.config import async_log_exception from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -30,7 +33,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG_DEVICE_INFO_ID = { "identifiers": ["helloworld"], @@ -62,6 +65,22 @@ _MqttMessageType = list[tuple[str, str]] _AttributesType = list[tuple[str, Any]] _StateDataType = list[tuple[_MqttMessageType, str | None, _AttributesType | None]] +MQTT_YAML_SCHEMA = vol.Schema({mqtt.DOMAIN: PLATFORM_CONFIG_SCHEMA_BASE}) + + +def help_test_validate_platform_config( + hass: HomeAssistant, domain: str, config: ConfigType +) -> ConfigType | None: + """Test the schema validation.""" + try: + # validate the schema + MQTT_YAML_SCHEMA(config) + return True + except vol.Error as exc: + # log schema exceptions + async_log_exception(exc, domain, config, hass) + return False + async def help_test_availability_when_connection_lost( hass: HomeAssistant, @@ -1764,7 +1783,7 @@ async def help_test_reload_with_config( new_yaml_config_file.write_text(new_yaml_config) assert new_yaml_config_file.read_text() == new_yaml_config - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): + with patch.object(module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): await hass.services.async_call( "mqtt", SERVICE_RELOAD, @@ -1785,9 +1804,9 @@ async def help_test_entry_reload_with_new_config( new_yaml_config_file.write_text(new_yaml_config) assert new_yaml_config_file.read_text() == new_yaml_config - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file), patch( - "paho.mqtt.client.Client" - ) as mock_client: + with patch.object( + module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file + ), patch("paho.mqtt.client.Client") as mock_client: mock_client().connect = lambda *args: 0 # reload the config entry assert await hass.config_entries.async_reload(mqtt_config_entry.entry_id) @@ -1797,13 +1816,12 @@ async def help_test_entry_reload_with_new_config( async def help_test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, domain: str, config: ConfigType, ) -> None: """Test reloading an MQTT platform.""" + # Set up with empty config config = copy.deepcopy(config[mqtt.DOMAIN][domain]) # Create and test an old config of 2 entities based on the config supplied old_config_1 = copy.deepcopy(config) @@ -1814,10 +1832,15 @@ async def help_test_reloadable( old_config = { mqtt.DOMAIN: {domain: [old_config_1, old_config_2]}, } - - assert await async_setup_component(hass, mqtt.DOMAIN, old_config) + # Start the MQTT entry with the old config + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) + entry.add_to_hass(hass) + mqtt_client_mock.connect.return_value = 0 + # We should call await mqtt.async_setup_entry(hass, entry) when async_setup + # is removed (this is planned with #87987). Until then we set up the mqtt component + # to test reload after the async_setup setup has set the initial config + await async_setup_component(hass, mqtt.DOMAIN, old_config) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") @@ -1835,8 +1858,15 @@ async def help_test_reloadable( new_config = { mqtt.DOMAIN: {domain: [new_config_1, new_config_2, new_config_extra]}, } - - await help_test_reload_with_config(hass, caplog, tmp_path, new_config) + module_hass_config.load_yaml_config_file.return_value = new_config + # Reload the mqtt entry with the new config + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() assert len(hass.states.async_all(domain)) == 3 @@ -1849,49 +1879,34 @@ async def help_test_setup_manual_entity_from_yaml( hass: HomeAssistant, config: ConfigType ) -> None: """Help to test setup from yaml through configuration entry.""" - calls = MagicMock() - - async def mock_reload(hass: HomeAssistant) -> None: - """Mock reload.""" - calls() - + # until `async_setup` does the initial config setup, we need to use + # async_setup_component to test with other yaml config assert await async_setup_component(hass, mqtt.DOMAIN, config) # Mock config entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) - with patch( - "homeassistant.components.mqtt.async_reload_manual_mqtt_items", - side_effect=mock_reload, - ), patch("paho.mqtt.client.Client") as mock_client: + with patch("paho.mqtt.client.Client") as mock_client: mock_client().connect = lambda *args: 0 assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - calls.assert_called_once() + await hass.async_block_till_done() -async def help_test_unload_config_entry( - hass: HomeAssistant, tmp_path: Path, newconfig: ConfigType -) -> None: +async def help_test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading the MQTT config entry.""" mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] assert mqtt_config_entry.state is ConfigEntryState.LOADED - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump(newconfig) - new_yaml_config_file.write_text(new_yaml_config) - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) - # work-a-round mypy bug https://github.com/python/mypy/issues/9005#issuecomment-1280985006 - updated_config_entry = mqtt_config_entry - assert updated_config_entry.state is ConfigEntryState.NOT_LOADED - await hass.async_block_till_done() + assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) + # work-a-round mypy bug https://github.com/python/mypy/issues/9005#issuecomment-1280985006 + updated_config_entry = mqtt_config_entry + assert updated_config_entry.state is ConfigEntryState.NOT_LOADED + await hass.async_block_till_done() async def help_test_unload_config_entry_with_platform( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: dict[str, dict[str, Any]], ) -> None: @@ -1900,9 +1915,9 @@ async def help_test_unload_config_entry_with_platform( config_setup: dict[str, dict[str, Any]] = copy.deepcopy(config) config_setup[mqtt.DOMAIN][domain]["name"] = "config_setup" config_name = config_setup + # To be replaced with entry setup when `async_setup` is removed. assert await async_setup_component(hass, mqtt.DOMAIN, config_setup) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() # prepare setup through discovery discovery_setup = copy.deepcopy(config[mqtt.DOMAIN][domain]) @@ -1919,7 +1934,7 @@ async def help_test_unload_config_entry_with_platform( discovery_setup_entity = hass.states.get(f"{domain}.discovery_setup") assert discovery_setup_entity - await help_test_unload_config_entry(hass, tmp_path, config_setup) + await help_test_unload_config_entry(hass) async_fire_mqtt_message( hass, f"homeassistant/{domain}/bla/config", json.dumps(discovery_setup) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 9b751c853638..27eac0842c28 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,5 +1,4 @@ """The tests for the MQTT cover platform.""" -from pathlib import Path from unittest.mock import patch import pytest @@ -78,7 +77,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {cover.DOMAIN: {"name": "test", "state_topic": "test-topic"}} @@ -3497,16 +3496,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = cover.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -3551,12 +3546,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = cover.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index fc987eac9358..feb81592393c 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -1,11 +1,9 @@ """The tests for MQTT device triggers.""" import json -from pathlib import Path from unittest.mock import patch import pytest -from homeassistant import config as hass_config import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt import _LOGGER, DOMAIN, debug_info @@ -1443,7 +1441,6 @@ async def test_unload_entry( calls, device_registry: dr.DeviceRegistry, mqtt_mock: MqttMockHAClient, - tmp_path: Path, ) -> None: """Test unloading the MQTT entry.""" @@ -1486,7 +1483,7 @@ async def test_unload_entry( await hass.async_block_till_done() assert len(calls) == 1 - await help_test_unload_config_entry(hass, tmp_path, {}) + await help_test_unload_config_entry(hass) # Rediscover message and fake short press 2 (non impact) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) @@ -1495,13 +1492,9 @@ async def test_unload_entry( await hass.async_block_till_done() assert len(calls) == 1 + # Start entry again mqtt_entry = hass.config_entries.async_entries("mqtt")[0] - - # Load the entry again - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config_file.write_text("") - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - await hass.config_entries.async_setup(mqtt_entry.entry_id) + await hass.config_entries.async_setup(mqtt_entry.entry_id) # Rediscover and fake short press 3 async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index a21f69544e80..d05c7109845b 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1588,7 +1588,7 @@ async def test_clean_up_registry_monitoring( # Enload the entry # The monitoring should be cleared - await help_test_unload_config_entry(hass, tmp_path, {}) + await help_test_unload_config_entry(hass) assert len(hooks) == 0 diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index fcdf887fbce2..1a0bc4faf52a 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1,6 +1,5 @@ """Test MQTT fans.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -66,7 +65,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message from tests.components.fan import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -2004,16 +2003,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = fan.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -2025,12 +2020,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = fan.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 761a0a81e7ac..653f5ea7810d 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1,6 +1,5 @@ """Test MQTT humidifiers.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -67,7 +66,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -1368,16 +1367,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = humidifier.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -1400,12 +1395,11 @@ async def test_config_schema_validation(hass: HomeAssistant) -> None: async def test_unload_config_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = humidifier.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index ec373aab0d7e..22090064280a 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -14,7 +14,7 @@ import pytest import voluptuous as vol import yaml -from homeassistant import config as hass_config +from homeassistant import config as module_hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import CONFIG_SCHEMA, debug_info from homeassistant.components.mqtt.client import EnsureJobAfterCooldown @@ -25,6 +25,7 @@ from homeassistant.const import ( ATTR_ASSUMED_STATE, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, + SERVICE_RELOAD, Platform, UnitOfTemperature, ) @@ -41,7 +42,6 @@ from homeassistant.util.dt import utcnow from .test_common import ( help_test_entry_reload_with_new_config, - help_test_reload_with_config, help_test_setup_manual_entity_from_yaml, ) @@ -121,20 +121,6 @@ def record_calls(calls: list[ReceiveMessage]) -> MessageCallbackType: return record_calls -@pytest.fixture -def empty_mqtt_config( - hass: HomeAssistant, tmp_path: Path -) -> Generator[Path, None, None]: - """Fixture to provide an empty config from yaml.""" - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config_file.write_text("") - - with patch.object( - hass_config, "YAML_CONFIG_FILE", new_yaml_config_file - ) as empty_config: - yield empty_config - - async def test_mqtt_connects_on_home_assistant_mqtt_setup( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -303,8 +289,21 @@ async def test_command_template_value(hass: HomeAssistant) -> None: @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SELECT]) +@pytest.mark.parametrize( + "config", + [ + { + "command_topic": "test/select", + "name": "Test Select", + "options": ["milk", "beer"], + "command_template": '{"option": "{{ value }}", "entity_id": "{{ entity_id }}", "name": "{{ name }}", "this_object_state": "{{ this.state }}"}', + } + ], +) async def test_command_template_variables( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + config: ConfigType, ) -> None: """Test the rendering of entity variables.""" topic = "test/select" @@ -312,22 +311,10 @@ async def test_command_template_variables( fake_state = ha.State("select.test_select", "milk") mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - "select": { - "command_topic": topic, - "name": "Test Select", - "options": ["milk", "beer"], - "command_template": '{"option": "{{ value }}", "entity_id": "{{ entity_id }}", "name": "{{ name }}", "this_object_state": "{{ this.state }}"}', - } - } - }, - ) + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/select/bla/config", json.dumps(config)) await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("select.test_select") assert state and state.state == "milk" @@ -1694,7 +1681,6 @@ async def test_initial_setup_logs_error( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mqtt_client_mock: MqttMockPahoClient, - empty_mqtt_config: Path, ) -> None: """Test for setup failure if initial client connection fails.""" entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) @@ -1838,7 +1824,7 @@ async def test_setup_override_configuration( new_yaml_config_file.write_text(new_yaml_config) assert new_yaml_config_file.read_text() == new_yaml_config - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): + with patch.object(module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): # Mock config entry entry = MockConfigEntry( domain=mqtt.DOMAIN, @@ -1914,26 +1900,43 @@ async def test_setup_manual_mqtt_empty_platform( @patch("homeassistant.components.mqtt.PLATFORMS", []) +@pytest.mark.parametrize( + ("mqtt_config_entry_data", "protocol"), + [ + ( + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_PROTOCOL: "3.1", + }, + 3, + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_PROTOCOL: "3.1.1", + }, + 4, + ), + ( + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_PROTOCOL: "5", + }, + 5, + ), + ], +) async def test_setup_mqtt_client_protocol( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + protocol: int, ) -> None: """Test MQTT client protocol setup.""" with patch("paho.mqtt.client.Client") as mock_client: - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - mqtt.config_integration.CONF_PROTOCOL: "3.1", - } - }, - ) - mock_client.on_connect(return_value=0) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() - # check if protocol setup was correctly - assert mock_client.call_args[1]["protocol"] == 3 + # check if protocol setup was correctly + assert mock_client.call_args[1]["protocol"] == protocol @patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.2) @@ -1999,18 +2002,20 @@ async def test_setup_raises_config_entry_not_ready_if_no_connect_broker( @pytest.mark.parametrize( - ("config", "insecure_param"), + ("mqtt_config_entry_data", "insecure_param"), [ - ({"certificate": "auto"}, "not set"), - ({"certificate": "auto", "tls_insecure": False}, False), - ({"certificate": "auto", "tls_insecure": True}, True), + ({"broker": "test-broker", "certificate": "auto"}, "not set"), + ( + {"broker": "test-broker", "certificate": "auto", "tls_insecure": False}, + False, + ), + ({"broker": "test-broker", "certificate": "auto", "tls_insecure": True}, True), ], ) @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - config: ConfigType, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, insecure_param: bool | str, ) -> None: """Test setup uses bundled certs when certificate is set to auto and insecure.""" @@ -2028,48 +2033,41 @@ async def test_setup_uses_certificate_on_certificate_set_to_auto_and_insecure( with patch("paho.mqtt.client.Client") as mock_client: mock_client().tls_set = mock_tls_set mock_client().tls_insecure_set = mock_tls_insecure_set - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: config}, - ) + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() - assert calls + assert calls - import certifi + import certifi - expected_certificate = certifi.where() - assert calls[0][0] == expected_certificate + expected_certificate = certifi.where() + assert calls[0][0] == expected_certificate - # test if insecure is set - assert insecure_check["insecure"] == insecure_param + # test if insecure is set + assert insecure_check["insecure"] == insecure_param +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_CERTIFICATE: "auto", + } + ], +) async def test_tls_version( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test setup defaults for tls.""" - calls = [] - - def mock_tls_set( - certificate, certfile=None, keyfile=None, tls_version=None - ) -> None: - calls.append((certificate, certfile, keyfile, tls_version)) - - with patch("paho.mqtt.client.Client") as mock_client: - mock_client().tls_set = mock_tls_set - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {"certificate": "auto"}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() - - assert calls - assert calls[0][3] == ssl.PROTOCOL_TLS_CLIENT + await mqtt_mock_entry_no_yaml_config() + await hass.async_block_till_done() + assert ( + mqtt_client_mock.tls_set.mock_calls[0][2]["tls_version"] + == ssl.PROTOCOL_TLS_CLIENT + ) @pytest.mark.parametrize( @@ -3258,7 +3256,6 @@ async def test_unload_config_entry( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, mqtt_client_mock: MqttMockPahoClient, - tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: """Test unloading the MQTT entry.""" @@ -3272,15 +3269,11 @@ async def test_unload_config_entry( mqtt_client_mock.reset_mock() mqtt.publish(hass, "just_in_time", "published", qos=0, retain=False) - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({}) - new_yaml_config_file.write_text(new_yaml_config) - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) - new_mqtt_config_entry = mqtt_config_entry - mqtt_client_mock.publish.assert_any_call("just_in_time", "published", 0, False) - assert new_mqtt_config_entry.state is ConfigEntryState.NOT_LOADED - await hass.async_block_till_done() + assert await hass.config_entries.async_unload(mqtt_config_entry.entry_id) + new_mqtt_config_entry = mqtt_config_entry + mqtt_client_mock.publish.assert_any_call("just_in_time", "published", 0, False) + assert new_mqtt_config_entry.state is ConfigEntryState.NOT_LOADED + await hass.async_block_till_done() assert not hass.services.has_service(mqtt.DOMAIN, "dump") assert not hass.services.has_service(mqtt.DOMAIN, "publish") assert "No ACK from MQTT server" not in caplog.text @@ -3318,131 +3311,108 @@ async def test_publish_or_subscribe_without_valid_config_entry( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) -async def test_reload_entry_with_new_config( - hass: HomeAssistant, tmp_path: Path +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": { + "light": [ + {"name": "test_new_modern", "command_topic": "test-topic_new"} + ] + } + } + ], +) +async def test_disabling_and_enabling_entry( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test reloading the config entry with a new yaml config.""" - config_old = { - "mqtt": {"light": [{"name": "test_old1", "command_topic": "test-topic_old"}]} - } - config_yaml_new = { - "mqtt": { - "light": [{"name": "test_new_modern", "command_topic": "test-topic_new"}] - }, - } - await help_test_setup_manual_entity_from_yaml(hass, config_old) - assert hass.states.get("light.test_old1") is not None + """Test disabling and enabling the config entry.""" + await mqtt_mock_entry_no_yaml_config() + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + assert entry.state is ConfigEntryState.LOADED + # Late discovery of a light + config = '{"name": "abc", "command_topic": "test-topic"}' + async_fire_mqtt_message(hass, "homeassistant/light/abc/config", config) + + # Disable MQTT config entry + await hass.config_entries.async_set_disabled_by( + entry.entry_id, ConfigEntryDisabler.USER + ) + + await hass.async_block_till_done() + await hass.async_block_till_done() + assert ( + "MQTT integration is disabled, skipping setup of discovered item MQTT light" + in caplog.text + ) + + new_mqtt_config_entry = entry + assert new_mqtt_config_entry.state is ConfigEntryState.NOT_LOADED + + # Enable the entry again + await hass.config_entries.async_set_disabled_by(entry.entry_id, None) + await hass.async_block_till_done() + await hass.async_block_till_done() + new_mqtt_config_entry = entry + assert new_mqtt_config_entry.state is ConfigEntryState.LOADED - await help_test_entry_reload_with_new_config(hass, tmp_path, config_yaml_new) - assert hass.states.get("light.test_old1") is None assert hass.states.get("light.test_new_modern") is not None -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) -async def test_disabling_and_enabling_entry( - hass: HomeAssistant, tmp_path: Path -) -> None: - """Test disabling and enabling the config entry.""" - config_old = { - "mqtt": {"light": [{"name": "test_old1", "command_topic": "test-topic_old"}]} - } - config_yaml_new = { - "mqtt": { - "light": [{"name": "test_new_modern", "command_topic": "test-topic_new"}] - }, - } - await help_test_setup_manual_entity_from_yaml(hass, config_old) - assert hass.states.get("light.test_old1") is not None - - mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - - assert mqtt_config_entry.state is ConfigEntryState.LOADED - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump(config_yaml_new) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file), patch( - "paho.mqtt.client.Client" - ) as mock_client: - mock_client().connect = lambda *args: 0 - - # Late discovery of a light - config = '{"name": "abc", "command_topic": "test-topic"}' - async_fire_mqtt_message(hass, "homeassistant/light/abc/config", config) - - # Disable MQTT config entry - await hass.config_entries.async_set_disabled_by( - mqtt_config_entry.entry_id, ConfigEntryDisabler.USER - ) - - await hass.async_block_till_done() - await hass.async_block_till_done() - - new_mqtt_config_entry = mqtt_config_entry - assert new_mqtt_config_entry.state is ConfigEntryState.NOT_LOADED - assert hass.states.get("light.test_old1") is None - - # Enable the entry again - await hass.config_entries.async_set_disabled_by( - mqtt_config_entry.entry_id, None - ) - await hass.async_block_till_done() - await hass.async_block_till_done() - new_mqtt_config_entry = mqtt_config_entry - assert new_mqtt_config_entry.state is ConfigEntryState.LOADED - - assert hass.states.get("light.test_old1") is None - assert hass.states.get("light.test_new_modern") is not None - - @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) @pytest.mark.parametrize( - ("config", "unique"), + ("hass_config", "unique"), [ ( - [ - { - "name": "test1", - "unique_id": "very_not_unique_deadbeef", - "command_topic": "test-topic_unique", - }, - { - "name": "test2", - "unique_id": "very_not_unique_deadbeef", - "command_topic": "test-topic_unique", - }, - ], + { + mqtt.DOMAIN: { + "light": [ + { + "name": "test1", + "unique_id": "very_not_unique_deadbeef", + "command_topic": "test-topic_unique", + }, + { + "name": "test2", + "unique_id": "very_not_unique_deadbeef", + "command_topic": "test-topic_unique", + }, + ] + } + }, False, ), ( - [ - { - "name": "test1", - "unique_id": "very_unique_deadbeef1", - "command_topic": "test-topic_unique", - }, - { - "name": "test2", - "unique_id": "very_unique_deadbeef2", - "command_topic": "test-topic_unique", - }, - ], + { + mqtt.DOMAIN: { + "light": [ + { + "name": "test1", + "unique_id": "very_unique_deadbeef1", + "command_topic": "test-topic_unique", + }, + { + "name": "test2", + "unique_id": "very_unique_deadbeef2", + "command_topic": "test-topic_unique", + }, + ] + } + }, True, ), ], ) async def test_setup_manual_items_with_unique_ids( hass: HomeAssistant, - tmp_path: Path, caplog: pytest.LogCaptureFixture, - config: ConfigType, + hass_config: ConfigType, unique: bool, ) -> None: """Test setup manual items is generating unique id's.""" - await help_test_setup_manual_entity_from_yaml( - hass, {mqtt.DOMAIN: {"light": config}} - ) + await help_test_setup_manual_entity_from_yaml(hass, hass_config) assert hass.states.get("light.test1") is not None assert (hass.states.get("light.test2") is not None) == unique @@ -3450,9 +3420,13 @@ async def test_setup_manual_items_with_unique_ids( # reload and assert again caplog.clear() - await help_test_entry_reload_with_new_config( - hass, tmp_path, {"mqtt": {"light": config}} + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, ) + await hass.async_block_till_done() assert hass.states.get("light.test1") is not None assert (hass.states.get("light.test2") is not None) == unique @@ -3489,21 +3463,26 @@ async def test_remove_unknown_conf_entry_options( @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": { + "light": [ + { + "name": "test_manual", + "unique_id": "test_manual_unique_id123", + "command_topic": "test-topic_manual", + } + ] + } + } + ], +) async def test_link_config_entry( - hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, hass_config: ConfigType, caplog: pytest.LogCaptureFixture ) -> None: """Test manual and dynamically setup entities are linked to the config entry.""" - config_manual = { - "mqtt": { - "light": [ - { - "name": "test_manual", - "unique_id": "test_manual_unique_id123", - "command_topic": "test-topic_manual", - } - ] - } - } config_discovery = { "name": "test_discovery", "unique_id": "test_discovery_unique456", @@ -3511,7 +3490,7 @@ async def test_link_config_entry( } # set up manual item - await help_test_setup_manual_entity_from_yaml(hass, config_manual) + await help_test_setup_manual_entity_from_yaml(hass, hass_config) # set up item through discovery async_fire_mqtt_message( @@ -3540,7 +3519,10 @@ async def test_link_config_entry( assert _check_entities() == 2 # reload entry and assert again - await help_test_entry_reload_with_new_config(hass, tmp_path, config_manual) + with patch("paho.mqtt.client.Client"): + await hass.config_entries.async_reload(mqtt_config_entry.entry_id) + await hass.async_block_till_done() + # manual set up item should remain assert _check_entities() == 1 # set up item through discovery @@ -3551,7 +3533,13 @@ async def test_link_config_entry( assert _check_entities() == 2 # reload manual configured items and assert again - await help_test_reload_with_config(hass, caplog, tmp_path, config_manual) + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() assert _check_entities() == 2 diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 70bf1deeb633..6d45cd4898ab 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -1,7 +1,6 @@ """The tests for the Legacy Mqtt vacuum platform.""" from copy import deepcopy import json -from pathlib import Path from unittest.mock import patch import pytest @@ -65,7 +64,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message from tests.components.vacuum import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -998,16 +997,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = vacuum.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index fcdec1fbfe39..a947bfc79735 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -169,7 +169,6 @@ mqtt: """ import copy -from pathlib import Path from unittest.mock import call, patch import pytest @@ -229,7 +228,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message, mock_restore_cache from tests.components.light import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {light.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -3034,16 +3033,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = light.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -3310,12 +3305,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = light.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index dc73d7e6d1ba..de8ba889e274 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -79,7 +79,6 @@ light: brightness_scale: 99 """ import copy -from pathlib import Path from unittest.mock import call, patch import pytest @@ -131,7 +130,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message, mock_restore_cache from tests.components.light import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -2280,16 +2279,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = light.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 2ad156d185fe..a5d0f009ca9e 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -25,7 +25,6 @@ If your light doesn't support color temp feature, omit `color_temp_template`. If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -77,7 +76,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message, mock_restore_cache from tests.components.light import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -1252,16 +1251,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = light.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1306,12 +1301,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = light.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index d186fe876717..1d48640011c9 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -58,7 +58,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {lock.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -970,16 +970,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = lock.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1022,12 +1018,11 @@ async def test_setup_manual_entity_from_yaml( async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = lock.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index a93cbc6790bd..da7e278e0307 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -1,6 +1,5 @@ """The tests for mqtt number component.""" import json -from pathlib import Path from unittest.mock import patch import pytest @@ -63,7 +62,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message, mock_restore_cache_with_extra_data -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {number.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -965,16 +964,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = number.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1016,12 +1011,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = number.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 57816e6a8552..b6899062d22c 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -1,6 +1,5 @@ """The tests for the MQTT scene platform.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -26,7 +25,7 @@ from .test_common import ( ) from tests.common import mock_restore_cache -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -244,16 +243,12 @@ async def test_discovery_broken( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = scene.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: @@ -265,12 +260,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = scene.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index e5599130ad8d..31eb7daff871 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -1,7 +1,6 @@ """The tests for mqtt select component.""" import copy import json -from pathlib import Path from unittest.mock import patch import pytest @@ -55,7 +54,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message, mock_restore_cache -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -711,16 +710,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = select.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -764,14 +759,13 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = select.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 3112564cb8a2..b40c2f43d04d 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -67,7 +67,7 @@ from tests.common import ( async_fire_time_changed, mock_restore_cache_with_extra_data, ) -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {sensor.DOMAIN: {"name": "test", "state_topic": "test-topic"}} @@ -1265,16 +1265,12 @@ async def test_value_template_with_entity_id( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = sensor.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) async def test_cleanup_triggers_and_restoring_state( @@ -1410,12 +1406,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = sensor.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 2683ad2e161a..329a9150f718 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -1,6 +1,5 @@ """The tests for the MQTT siren platform.""" import copy -from pathlib import Path from typing import Any from unittest.mock import patch @@ -53,7 +52,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {siren.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -1005,16 +1004,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = siren.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -1055,12 +1050,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = siren.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 5162354e7cc8..55a2a773f6cf 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -1,7 +1,6 @@ """The tests for the State vacuum Mqtt platform.""" from copy import deepcopy import json -from pathlib import Path from unittest.mock import patch import pytest @@ -62,7 +61,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message from tests.components.vacuum import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient COMMAND_TOPIC = "vacuum/command" SEND_COMMAND_TOPIC = "vacuum/send_command" @@ -718,16 +717,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = vacuum.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index ccc26599c6ba..1eb229461878 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -1,6 +1,5 @@ """The tests for the MQTT switch platform.""" import copy -from pathlib import Path from unittest.mock import patch import pytest @@ -49,7 +48,7 @@ from .test_common import ( from tests.common import async_fire_mqtt_message, mock_restore_cache from tests.components.switch import common -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {switch.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -681,16 +680,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = switch.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -731,12 +726,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = switch.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 6c0472a11b86..2f7a9d0cc06f 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -1,7 +1,6 @@ """The tests for MQTT tag scanner.""" import copy import json -from pathlib import Path from unittest.mock import ANY, patch import pytest @@ -893,7 +892,6 @@ async def test_unload_entry( device_registry: dr.DeviceRegistry, mqtt_mock: MqttMockHAClient, tag_mock, - tmp_path: Path, ) -> None: """Test unloading the MQTT entry.""" @@ -910,7 +908,7 @@ async def test_unload_entry( tag_mock.reset_mock() - await help_test_unload_config_entry(hass, tmp_path, {}) + await help_test_unload_config_entry(hass) await hass.async_block_till_done() # Fake tag scan, should not be processed diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index a5209e3f5fd5..83b2a0d55ebb 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -1,7 +1,6 @@ """The tests for the MQTT text platform.""" from __future__ import annotations -from pathlib import Path from unittest.mock import patch import pytest @@ -47,7 +46,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: {text.DOMAIN: {"name": "test", "command_topic": "test-topic"}} @@ -695,16 +694,12 @@ async def test_publishing_with_custom_encoding( async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = text.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) @pytest.mark.parametrize( @@ -745,12 +740,11 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = text.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index d815dbc8b19c..4821aeca8eb9 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -1,6 +1,5 @@ """The tests for mqtt update component.""" import json -from pathlib import Path from unittest.mock import patch import pytest @@ -43,7 +42,7 @@ from .test_common import ( ) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -678,26 +677,21 @@ async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: async def test_unload_entry( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - tmp_path: Path, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test unloading the config entry.""" domain = update.DOMAIN config = DEFAULT_CONFIG await help_test_unload_config_entry_with_platform( - hass, mqtt_mock_entry_with_yaml_config, tmp_path, domain, config + hass, mqtt_mock_entry_no_yaml_config, domain, config ) async def test_reloadable( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - tmp_path: Path, + mqtt_client_mock: MqttMockPahoClient, ) -> None: """Test reloading the MQTT platform.""" domain = update.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable( - hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config - ) + await help_test_reloadable(hass, mqtt_client_mock, domain, config) From 9384ec18f847226276f0189f6e7039ee6fcd8c74 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Mar 2023 15:59:51 +0100 Subject: [PATCH 0535/1058] Add filters to climate and light service descriptions (#86162) * Add filters to climate and light service descriptions * Allow specifying enums directly * Update service descriptions * Adjust test * Cache entity features * Lint * Improve error handling, add list of known base components * Don't allow specifying an entity feature as int --- .yamllint | 2 +- .../components/climate/services.yaml | 22 ++ homeassistant/components/light/services.yaml | 204 ++++++++++++++++++ homeassistant/helpers/selector.py | 68 ++++++ homeassistant/helpers/service.py | 120 ++++++++++- script/hassfest/services.py | 14 +- tests/helpers/test_selector.py | 51 ++++- 7 files changed, 467 insertions(+), 14 deletions(-) diff --git a/.yamllint b/.yamllint index c2f877a2b7a0..e587d75d7992 100644 --- a/.yamllint +++ b/.yamllint @@ -25,7 +25,7 @@ rules: comments: level: error require-starting-space: true - min-spaces-from-content: 2 + min-spaces-from-content: 1 comments-indentation: level: error document-end: diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 40d518456b4e..33e114c87f5d 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -6,6 +6,8 @@ set_aux_heat: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.AUX_HEAT fields: aux_heat: name: Auxiliary heating @@ -20,6 +22,8 @@ set_preset_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.PRESET_MODE fields: preset_mode: name: Preset mode @@ -35,10 +39,16 @@ set_temperature: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE fields: temperature: name: Temperature description: New target temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE selector: number: min: 0 @@ -48,6 +58,9 @@ set_temperature: target_temp_high: name: Target temperature high description: New target high temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE advanced: true selector: number: @@ -58,6 +71,9 @@ set_temperature: target_temp_low: name: Target temperature low description: New target low temperature for HVAC. + filter: + supported_features: + - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE advanced: true selector: number: @@ -92,6 +108,8 @@ set_humidity: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.TARGET_HUMIDITY fields: humidity: name: Humidity @@ -109,6 +127,8 @@ set_fan_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.FAN_MODE fields: fan_mode: name: Fan mode @@ -152,6 +172,8 @@ set_swing_mode: target: entity: domain: climate + supported_features: + - climate.ClimateEntityFeature.SWING_MODE fields: swing_mode: name: Swing mode diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index b7843a2f0ec3..65bf77f15c75 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -12,6 +12,9 @@ turn_on: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -20,11 +23,27 @@ turn_on: rgb_color: name: Color description: The color for the light (based on RGB - red, green, blue). + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: color_rgb: rgbw_color: name: RGBW-color description: A list containing four integers between 0 and 255 representing the RGBW (red, green, blue, white) color for the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100, 50]" selector: @@ -32,6 +51,14 @@ turn_on: rgbww_color: name: RGBWW-color description: A list containing five integers between 0 and 255 representing the RGBWW (red, green, blue, cold white, warm white) color for the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100, 50, 70]" selector: @@ -39,6 +66,14 @@ turn_on: color_name: name: Color name description: A human readable color name. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: select: @@ -195,6 +230,14 @@ turn_on: hs_color: name: Hue/Sat color description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[300, 70]" selector: @@ -202,6 +245,14 @@ turn_on: xy_color: name: XY-color description: Color for the light in XY-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[0.52, 0.43]" selector: @@ -209,6 +260,15 @@ turn_on: color_temp: name: Color temperature description: Color temperature for the light in mireds. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: color_temp: min_mireds: 153 @@ -216,6 +276,15 @@ turn_on: kelvin: name: Color temperature (Kelvin) description: Color temperature for the light in Kelvin. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -228,6 +297,16 @@ turn_on: description: Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -238,6 +317,16 @@ turn_on: description: Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: 0 @@ -246,6 +335,16 @@ turn_on: brightness_step: name: Brightness step value description: Change brightness by an amount. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -254,6 +353,16 @@ turn_on: brightness_step_pct: name: Brightness step description: Change brightness by a percentage. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: -100 @@ -265,6 +374,10 @@ turn_on: Set the light to white mode and change its brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.WHITE advanced: true selector: number: @@ -280,6 +393,9 @@ turn_on: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -291,6 +407,9 @@ turn_on: effect: name: Effect description: Light effect. + filter: + supported_features: + - light.LightEntityFeature.EFFECT selector: text: @@ -304,6 +423,9 @@ turn_off: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -312,6 +434,9 @@ turn_off: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -333,6 +458,9 @@ toggle: transition: name: Transition description: Duration it takes to get to next state. + filter: + supported_features: + - light.LightEntityFeature.TRANSITION selector: number: min: 0 @@ -341,6 +469,14 @@ toggle: rgb_color: name: RGB-color description: Color for the light in RGB-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[255, 100, 100]" selector: @@ -348,6 +484,14 @@ toggle: color_name: name: Color name description: A human readable color name. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: select: @@ -504,6 +648,14 @@ toggle: hs_color: name: Hue/Sat color description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[300, 70]" selector: @@ -511,6 +663,14 @@ toggle: xy_color: name: XY-color description: Color for the light in XY-format. + filter: + attribute: + supported_color_modes: + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true example: "[0.52, 0.43]" selector: @@ -518,12 +678,30 @@ toggle: color_temp: name: Color temperature (mireds) description: Color temperature for the light in mireds. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: color_temp: kelvin: name: Color temperature (Kelvin) description: Color temperature for the light in Kelvin. + filter: + attribute: + supported_color_modes: + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -536,6 +714,16 @@ toggle: description: Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW advanced: true selector: number: @@ -546,6 +734,16 @@ toggle: description: Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light. + filter: + attribute: + supported_color_modes: + - light.ColorMode.BRIGHTNESS + - light.ColorMode.COLOR_TEMP + - light.ColorMode.HS + - light.ColorMode.XY + - light.ColorMode.RGB + - light.ColorMode.RGBW + - light.ColorMode.RGBWW selector: number: min: 0 @@ -561,6 +759,9 @@ toggle: flash: name: Flash description: If the light should flash. + filter: + supported_features: + - light.LightEntityFeature.FLASH advanced: true selector: select: @@ -572,5 +773,8 @@ toggle: effect: name: Effect description: Light effect. + filter: + supported_features: + - light.LightEntityFeature.EFFECT selector: text: diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 865ea6374e12..e2f58e357ed5 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -2,6 +2,8 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence +from enum import IntFlag +from functools import cache from typing import Any, Generic, Literal, TypedDict, TypeVar, cast from uuid import UUID @@ -79,6 +81,69 @@ class Selector(Generic[_T]): return {"selector": {self.selector_type: self.config}} +@cache +def _entity_features() -> dict[str, type[IntFlag]]: + """Return a cached lookup of entity feature enums.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntityFeature, + ) + from homeassistant.components.calendar import CalendarEntityFeature + from homeassistant.components.camera import CameraEntityFeature + from homeassistant.components.climate import ClimateEntityFeature + from homeassistant.components.cover import CoverEntityFeature + from homeassistant.components.fan import FanEntityFeature + from homeassistant.components.humidifier import HumidifierEntityFeature + from homeassistant.components.light import LightEntityFeature + from homeassistant.components.lock import LockEntityFeature + from homeassistant.components.media_player import MediaPlayerEntityFeature + from homeassistant.components.remote import RemoteEntityFeature + from homeassistant.components.siren import SirenEntityFeature + from homeassistant.components.update import UpdateEntityFeature + from homeassistant.components.vacuum import VacuumEntityFeature + from homeassistant.components.water_heater import WaterHeaterEntityFeature + + return { + "AlarmControlPanelEntityFeature": AlarmControlPanelEntityFeature, + "CalendarEntityFeature": CalendarEntityFeature, + "CameraEntityFeature": CameraEntityFeature, + "ClimateEntityFeature": ClimateEntityFeature, + "CoverEntityFeature": CoverEntityFeature, + "FanEntityFeature": FanEntityFeature, + "HumidifierEntityFeature": HumidifierEntityFeature, + "LightEntityFeature": LightEntityFeature, + "LockEntityFeature": LockEntityFeature, + "MediaPlayerEntityFeature": MediaPlayerEntityFeature, + "RemoteEntityFeature": RemoteEntityFeature, + "SirenEntityFeature": SirenEntityFeature, + "UpdateEntityFeature": UpdateEntityFeature, + "VacuumEntityFeature": VacuumEntityFeature, + "WaterHeaterEntityFeature": WaterHeaterEntityFeature, + } + + +def _validate_supported_feature(supported_feature: int | str) -> int: + """Validate a supported feature and resolve an enum string to its value.""" + + if isinstance(supported_feature, int): + return supported_feature + + known_entity_features = _entity_features() + + try: + _, enum, feature = supported_feature.split(".", 2) + except ValueError as exc: + raise vol.Invalid( + f"Invalid supported feature '{supported_feature}', expected " + ".." + ) from exc + + try: + return cast(int, getattr(known_entity_features[enum], feature).value) + except (AttributeError, KeyError) as exc: + raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc + + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration that provided the entity @@ -87,6 +152,8 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("domain"): vol.All(cv.ensure_list, [str]), # Device class of the entity vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), + # Features supported by the entity + vol.Optional("supported_features"): [vol.All(str, _validate_supported_feature)], } ) @@ -97,6 +164,7 @@ class EntityFilterSelectorConfig(TypedDict, total=False): integration: str domain: str | list[str] device_class: str | list[str] + supported_features: list[str] DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9f6f65f1d2de..33c677454bc8 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -4,9 +4,11 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Iterable import dataclasses -from functools import partial, wraps +from enum import Enum +from functools import cache, partial, wraps import logging -from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar +from types import ModuleType +from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, TypeVar, cast import voluptuous as vol @@ -42,6 +44,7 @@ from . import ( entity_registry, template, ) +from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType if TYPE_CHECKING: @@ -58,6 +61,112 @@ _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" +@cache +def _base_components() -> dict[str, ModuleType]: + """Return a cached lookup of base components.""" + # pylint: disable=import-outside-toplevel + from homeassistant.components import ( + alarm_control_panel, + calendar, + camera, + climate, + cover, + fan, + humidifier, + light, + lock, + media_player, + remote, + siren, + update, + vacuum, + water_heater, + ) + + return { + "alarm_control_panel": alarm_control_panel, + "calendar": calendar, + "camera": camera, + "climate": climate, + "cover": cover, + "fan": fan, + "humidifier": humidifier, + "light": light, + "lock": lock, + "media_player": media_player, + "remote": remote, + "siren": siren, + "update": update, + "vacuum": vacuum, + "water_heater": water_heater, + } + + +def _validate_option_or_feature(option_or_feature: str, label: str) -> Any: + """Validate attribute option or supported feature.""" + try: + domain, enum, option = option_or_feature.split(".", 2) + except ValueError as exc: + raise vol.Invalid( + f"Invalid {label} '{option_or_feature}', expected " + ".." + ) from exc + + base_components = _base_components() + if not (base_component := base_components.get(domain)): + raise vol.Invalid(f"Unknown base component '{domain}'") + + try: + attribute_enum = getattr(base_component, enum) + except AttributeError as exc: + raise vol.Invalid(f"Unknown {label} enum '{domain}.{enum}'") from exc + + if not issubclass(attribute_enum, Enum): + raise vol.Invalid(f"Expected {label} '{domain}.{enum}' to be an enum") + + try: + return getattr(attribute_enum, option).value + except AttributeError as exc: + raise vol.Invalid(f"Unknown {label} '{enum}.{option}'") from exc + + +def validate_attribute_option(attribute_option: str) -> Any: + """Validate attribute option.""" + return _validate_option_or_feature(attribute_option, "attribute option") + + +def validate_supported_feature(supported_feature: str) -> Any: + """Validate supported feature.""" + return _validate_option_or_feature(supported_feature, "supported feature") + + +# Basic schemas which translate attribute and supported feature enum names +# to their values. Full validation is done by hassfest.services +_FIELD_SCHEMA = vol.Schema( + { + vol.Optional("filter"): { + vol.Optional("attribute"): { + vol.Required(str): [vol.All(str, validate_attribute_option)], + }, + vol.Optional("supported_features"): [ + vol.All(str, validate_supported_feature) + ], + }, + }, + extra=vol.ALLOW_EXTRA, +) + +_SERVICE_SCHEMA = vol.Schema( + { + vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None), + vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + +_SERVICES_SCHEMA = vol.Schema({cv.slug: _SERVICE_SCHEMA}) + + class ServiceParams(TypedDict): """Type for service call parameters.""" @@ -421,13 +530,16 @@ async def async_extract_config_entry_ids( def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: """Load services file for an integration.""" try: - return load_yaml(str(integration.file_path / "services.yaml")) + return cast( + JSON_TYPE, + _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + ) except FileNotFoundError: _LOGGER.warning( "Unable to find services.yaml for the %s integration", integration.domain ) return {} - except HomeAssistantError: + except (HomeAssistantError, vol.Invalid): _LOGGER.warning( "Unable to parse services.yaml for the %s integration", integration.domain ) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index d9351b803708..bb9693139671 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -10,7 +10,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv, selector, service from homeassistant.util.yaml import load_yaml from .model import Config, Integration @@ -33,6 +33,14 @@ FIELD_SCHEMA = vol.Schema( vol.Optional("required"): bool, vol.Optional("advanced"): bool, vol.Optional(CONF_SELECTOR): selector.validate_selector, + vol.Optional("filter"): { + vol.Optional("attribute"): { + vol.Required(str): [vol.All(str, service.validate_attribute_option)], + }, + vol.Optional("supported_features"): [ + vol.All(str, service.validate_supported_feature) + ], + }, } ) @@ -40,9 +48,7 @@ SERVICE_SCHEMA = vol.Schema( { vol.Required("description"): str, vol.Optional("name"): str, - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None # pylint: disable=no-member - ), + vol.Optional("target"): vol.Any(selector.TargetSelector.CONFIG_SCHEMA, None), vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), } ) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 6f1cb2baef76..0a24eac38c61 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -69,10 +69,9 @@ def _test_selector( # Serialize selector selector_instance = selector.selector({selector_type: schema}) - assert ( - selector.selector(selector_instance.serialize()["selector"]).config - == selector_instance.config - ) + assert selector_instance.serialize() == { + "selector": {selector_type: selector_instance.config} + } # Test serialized selector can be dumped to YAML yaml.dump(selector_instance.serialize()) @@ -227,6 +226,29 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> ("light.abc123", "binary_sensor.abc123", FAKE_UUID), (None,), ), + ( + { + "filter": [ + {"supported_features": ["light.LightEntityFeature.EFFECT"]}, + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), + ( + { + "filter": [ + { + "supported_features": [ + "light.LightEntityFeature.EFFECT", + "light.LightEntityFeature.TRANSITION", + ] + }, + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), ), ) def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -234,6 +256,25 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("entity", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + "schema", + ( + # Feature should be string specifying an enum member, not an int + {"filter": [{"supported_features": [1]}]}, + # Invalid feature + {"filter": [{"supported_features": ["blah"]}]}, + # Unknown feature enum + {"filter": [{"supported_features": ["blah.FooEntityFeature.blah"]}]}, + # Unknown feature enum member + {"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]}, + ), +) +def test_entity_selector_schema_error(schema) -> None: + """Test number selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"entity": schema}) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), ( @@ -359,7 +400,7 @@ def test_addon_selector_schema(schema, valid_selections, invalid_selections) -> @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), - (({}, (1, "one", None), ()),), # Everything can be coarced to bool + (({}, (1, "one", None), ()),), # Everything can be coerced to bool ) def test_boolean_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test boolean selector.""" From 6e25abfdccf987d72e6302177321f7f622e275e6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 16 Mar 2023 16:54:26 +0100 Subject: [PATCH 0536/1058] Fix typo in docstr (#89804) --- tests/components/mqtt/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 696ad28b7351..9fe04c459fe6 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -8,4 +8,4 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 @pytest.fixture(autouse=True) def patch_hass_config(mock_hass_config: None) -> None: - """Patch configuration].yaml.""" + """Patch configuration.yaml.""" From 1a7e316b51a099e58971935417f9b169e34a1dd1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 18:19:29 +0100 Subject: [PATCH 0537/1058] Fix lingering timer in condition tests (#89807) --- tests/helpers/test_condition.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index e345da1538c7..fdbe9eb316b4 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, patch import pytest import voluptuous as vol -from homeassistant.components import sun import homeassistant.components.automation as automation from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( @@ -39,15 +38,6 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]: return async_mock_service(hass, "test", "automation") -@pytest.fixture(autouse=True) -def setup_comp(hass: HomeAssistant) -> None: - """Initialize components.""" - hass.config.set_time_zone(hass.config.time_zone) - hass.loop.run_until_complete( - async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) - ) - - def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. From ba4a638b398096cf58164357a005e82a3a7e6f7e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 16 Mar 2023 18:32:07 +0100 Subject: [PATCH 0538/1058] Update frontend to 20230309.1 (#89802) --- 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 a4d97201c5fe..2c13e81ee3c2 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==20230309.0"] + "requirements": ["home-assistant-frontend==20230309.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4bcddab1f8d4..17693b31f68d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ fnvhash==0.1.0 hass-nabucasa==0.61.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 home-assistant-intents==2023.2.28 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index fb76c2107379..6c7fbf82bcde 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 # homeassistant.components.conversation home-assistant-intents==2023.2.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e88193d5618..53477f0912a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.18.0 # homeassistant.components.frontend -home-assistant-frontend==20230309.0 +home-assistant-frontend==20230309.1 # homeassistant.components.conversation home-assistant-intents==2023.2.28 From 69aa3a75c5575e30513f7c27c971d540585f8fe1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 18:32:34 +0100 Subject: [PATCH 0539/1058] Fix lingering timer in event sun tests (#89808) --- tests/helpers/test_event.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index d9ad81561cce..afc960f4e198 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -14,7 +14,6 @@ from freezegun.api import FrozenDateTimeFactory import jinja2 import pytest -from homeassistant.components import sun from homeassistant.const import MATCH_ALL import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback @@ -3421,7 +3420,6 @@ async def test_track_sunrise(hass: HomeAssistant) -> None: # Setup sun component hass.config.latitude = latitude hass.config.longitude = longitude - assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) location = LocationInfo( latitude=hass.config.latitude, longitude=hass.config.longitude @@ -3486,7 +3484,6 @@ async def test_track_sunrise_update_location(hass: HomeAssistant) -> None: # Setup sun component hass.config.latitude = 32.87336 hass.config.longitude = 117.22743 - assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) location = LocationInfo( latitude=hass.config.latitude, longitude=hass.config.longitude @@ -3508,7 +3505,7 @@ async def test_track_sunrise_update_location(hass: HomeAssistant) -> None: # Track sunrise runs = [] with freeze_time(utc_now): - async_track_sunrise(hass, callback(lambda: runs.append(1))) + unsub = async_track_sunrise(hass, callback(lambda: runs.append(1))) # Mimic sunrise with freeze_time(next_rising): @@ -3549,6 +3546,8 @@ async def test_track_sunrise_update_location(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(runs) == 2 + unsub() + async def test_track_sunset(hass: HomeAssistant) -> None: """Test track the sunset.""" @@ -3560,7 +3559,6 @@ async def test_track_sunset(hass: HomeAssistant) -> None: # Setup sun component hass.config.latitude = latitude hass.config.longitude = longitude - assert await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) # Get next sunrise/sunset utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC) From 3e89b81e1da1e88c7345662edd3581f636a4de3e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:03:23 +0100 Subject: [PATCH 0540/1058] Add state attribute translations for calendars (#89811) --- .../components/calendar/strings.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index bcc07bb7fccd..73c54bbba6a1 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -5,6 +5,26 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "all_day": { + "name": "All day" + }, + "description": { + "name": "Description" + }, + "end_time": { + "name": "End time" + }, + "location": { + "name": "Location" + }, + "messages": { + "name": "Message" + }, + "start_time": { + "name": "Start time" + } } } } From cb8ed4a1cc33ed87aad53a686edf903b1e978058 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:04:11 +0100 Subject: [PATCH 0541/1058] Add state attribute translations for alarm control panel (#89809) --- .../components/alarm_control_panel/strings.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 1d8a29f7dd8f..f05d04dd78c1 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -40,6 +40,17 @@ "arming": "Arming", "disarming": "Disarming", "triggered": "Triggered" + }, + "state_attributes": { + "code_format": { + "name": "Code format" + }, + "changed_by": { + "name": "Changed by" + }, + "code_arm_required": { + "name": "Code for arming required" + } } } } From d99c02b46f40e38e9ad890bec0c29a588f880142 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:04:25 +0100 Subject: [PATCH 0542/1058] Add state attribute translations for covers (#89812) --- homeassistant/components/cover/strings.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index d17508b71db2..dd728b41136c 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -35,6 +35,14 @@ "closed": "[%key:common::state::closed%]", "closing": "Closing", "stopped": "Stopped" + }, + "state_attributes": { + "current_position": { + "name": "Position" + }, + "current_tilt_position": { + "name": "Tilt position" + } } } } From 298dae55fae2eea30bab0a789e41e8e3bb78891b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:06:35 +0100 Subject: [PATCH 0543/1058] Add missing state translations for Lock entities (#89795) --- homeassistant/components/lock/strings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index ab7a1632ea7a..e6c9884457a1 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -18,8 +18,11 @@ "entity_component": { "_": { "state": { + "jammed": "Jammed", "locked": "[%key:common::state::locked%]", - "unlocked": "[%key:common::state::unlocked%]" + "locking": "Locking", + "unlocked": "[%key:common::state::unlocked%]", + "unlocking": "Unlocking" } } } From e6f280cf7a9c93e878febc2f0090031159ad1133 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 19:58:52 +0100 Subject: [PATCH 0544/1058] Add state attribute translations for device trackers (#89810) --- .../components/device_tracker/strings.json | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index a1c50c88f861..e9232a72bb8e 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -15,6 +15,29 @@ "state": { "home": "[%key:common::state::home%]", "not_home": "[%key:common::state::not_home%]" + }, + "state_attributes": { + "battery": { + "name": "Battery" + }, + "gps_accuracy": { + "name": "GPS accuracy" + }, + "latitude": { + "name": "Latitude" + }, + "longitude": { + "name": "Longitude" + }, + "source_type": { + "name": "Source", + "state": { + "bluetooth_le": "Bluetooth LE", + "bluetooth": "Bluetooth", + "gps": "GPS", + "router": "Router" + } + } } } } From f9919bb7cfd58ea569152bd866c9ba28fa263483 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Mar 2023 21:10:20 +0100 Subject: [PATCH 0545/1058] Add pre-defined entity name translations (#89792) --- .../alarm_control_panel/strings.json | 1 + homeassistant/components/alert/strings.json | 1 + .../components/automation/strings.json | 1 + .../components/binary_sensor/strings.json | 27 ++++ homeassistant/components/button/strings.json | 11 ++ .../components/calendar/strings.json | 1 + homeassistant/components/camera/strings.json | 1 + homeassistant/components/climate/strings.json | 1 + .../components/configurator/strings.json | 1 + homeassistant/components/cover/strings.json | 31 ++++ .../components/device_tracker/strings.json | 1 + homeassistant/components/fan/strings.json | 1 + homeassistant/components/group/strings.json | 1 + .../components/humidifier/strings.json | 7 + .../components/input_boolean/strings.json | 1 + homeassistant/components/light/strings.json | 1 + homeassistant/components/lock/strings.json | 1 + .../components/media_player/strings.json | 10 ++ homeassistant/components/number/strings.json | 131 +++++++++++++++++ homeassistant/components/person/strings.json | 1 + homeassistant/components/plant/strings.json | 1 + homeassistant/components/remote/strings.json | 1 + .../components/schedule/strings.json | 1 + homeassistant/components/script/strings.json | 1 + homeassistant/components/sensor/strings.json | 139 ++++++++++++++++++ homeassistant/components/sun/strings.json | 1 + homeassistant/components/switch/strings.json | 7 + homeassistant/components/timer/strings.json | 1 + homeassistant/components/update/strings.json | 8 + homeassistant/components/vacuum/strings.json | 1 + .../components/water_heater/strings.json | 1 + homeassistant/components/weather/strings.json | 1 + script/hassfest/translations.py | 1 + tests/helpers/test_translation.py | 11 +- 34 files changed, 402 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index f05d04dd78c1..f055d6646b6e 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -28,6 +28,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::alarm_control_panel::title%]", "state": { "armed": "Armed", "disarmed": "Disarmed", diff --git a/homeassistant/components/alert/strings.json b/homeassistant/components/alert/strings.json index 9975e6ee0df9..4d948b2f4d11 100644 --- a/homeassistant/components/alert/strings.json +++ b/homeassistant/components/alert/strings.json @@ -2,6 +2,7 @@ "title": "Alert", "entity_component": { "_": { + "name": "[%key:component::alert::title%]", "state": { "idle": "[%key:common::state::idle%]", "off": "Acknowledged", diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index ffadc47a48cd..1663443f0457 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -2,6 +2,7 @@ "title": "Automation", "entity_component": { "_": { + "name": "[%key:component::automation::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 8d787204dba7..a0dc2020f01a 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -108,162 +108,189 @@ }, "entity_component": { "_": { + "name": "[%key:component::binary_sensor::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } }, "battery": { + "name": "Battery", "state": { "off": "Normal", "on": "Low" } }, "battery_charging": { + "name": "Charging", "state": { "off": "Not charging", "on": "Charging" } }, "carbon_monoxide": { + "name": "Carbon monoxide", "state": { "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "cold": { + "name": "Cold", "state": { "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", "on": "Cold" } }, "connectivity": { + "name": "Connectivity", "state": { "off": "[%key:common::state::disconnected%]", "on": "[%key:common::state::connected%]" } }, "door": { + "name": "Door", "state": { "off": "[%key:common::state::closed%]", "on": "[%key:common::state::open%]" } }, "garage_door": { + "name": "Garage door", "state": { "off": "[%key:common::state::closed%]", "on": "[%key:common::state::open%]" } }, "gas": { + "name": "Gas", "state": { "off": "Clear", "on": "Detected" } }, "heat": { + "name": "Heat", "state": { "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]", "on": "Hot" } }, "light": { + "name": "Light", "state": { "off": "No light", "on": "Light detected" } }, "lock": { + "name": "Lock", "state": { "off": "[%key:common::state::locked%]", "on": "[%key:common::state::unlocked%]" } }, "moisture": { + "name": "Moisture", "state": { "off": "Dry", "on": "Wet" } }, "motion": { + "name": "Motion", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "moving": { + "name": "Moving", "state": { "off": "Not moving", "on": "Moving" } }, "occupancy": { + "name": "Occupancy", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "opening": { + "name": "Opening", "state": { "off": "[%key:common::state::closed%]", "on": "[%key:common::state::open%]" } }, "plug": { + "name": "Plug", "state": { "off": "Unplugged", "on": "Plugged in" } }, "presence": { + "name": "Presence", "state": { "off": "[%key:component::device_tracker::entity_component::_::state::not_home%]", "on": "[%key:component::device_tracker::entity_component::_::state::home%]" } }, "problem": { + "name": "Problem", "state": { "off": "OK", "on": "Problem" } }, "running": { + "name": "Running", "state": { "off": "Not running", "on": "Running" } }, "safety": { + "name": "Safety", "state": { "off": "Safe", "on": "Unsafe" } }, "smoke": { + "name": "Smoke", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "sound": { + "name": "Sound", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "update": { + "name": "Update", "state": { "off": "Up-to-date", "on": "Update available" } }, "vibration": { + "name": "Vibration", "state": { "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" } }, "window": { + "name": "Window", "state": { "off": "[%key:common::state::closed%]", "on": "[%key:common::state::open%]" diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index ca774c57d773..4fd888538937 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -7,5 +7,16 @@ "action_type": { "press": "Press {entity_name} button" } + }, + "entity_component": { + "_": { + "name": "[%key:component::button::title%]" + }, + "restart": { + "name": "Restart" + }, + "update": { + "name": "Update" + } } } diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 73c54bbba6a1..2663408cfdbd 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -2,6 +2,7 @@ "title": "Calendar", "entity_component": { "_": { + "name": "[%key:component::calendar::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 5bde2ed25173..06ddaeeb092b 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -2,6 +2,7 @@ "title": "Camera", "entity_component": { "_": { + "name": "[%key:component::camera::title%]", "state": { "recording": "Recording", "streaming": "Streaming", diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 16cf9a130bb2..5e3fe15d5667 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -17,6 +17,7 @@ }, "entity_component": { "_": { + "name": "Thermostat", "state": { "off": "[%key:common::state::off%]", "heat": "Heat", diff --git a/homeassistant/components/configurator/strings.json b/homeassistant/components/configurator/strings.json index c48f1d838581..0574e4bfcedc 100644 --- a/homeassistant/components/configurator/strings.json +++ b/homeassistant/components/configurator/strings.json @@ -2,6 +2,7 @@ "title": "Configurator", "entity_component": { "_": { + "name": "[%key:component::configurator::title%]", "state": { "configure": "Configure", "configured": "Configured" diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index dd728b41136c..2f61bd95083e 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -29,6 +29,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::cover::title%]", "state": { "open": "[%key:common::state::open%]", "opening": "Opening", @@ -44,6 +45,36 @@ "name": "Tilt position" } } + }, + "awning": { + "name": "Awning" + }, + "blind": { + "name": "Blind" + }, + "curtain": { + "name": "Curtain" + }, + "damper": { + "name": "Damper" + }, + "door": { + "name": "Door" + }, + "garage": { + "name": "Garage" + }, + "gate": { + "name": "Gate" + }, + "shade": { + "name": "Shade" + }, + "shutter": { + "name": "Shutter" + }, + "window": { + "name": "Window" } } } diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index e9232a72bb8e..c15b9723c972 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -12,6 +12,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::device_tracker::title%]", "state": { "home": "[%key:common::state::home%]", "not_home": "[%key:common::state::not_home%]" diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 670d11b76baa..9aae5cf1642d 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -18,6 +18,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::fan::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 17f63167dbe6..e78fe982d5db 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -157,6 +157,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::group::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index e536def16771..0fca7c0a0a20 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -22,10 +22,17 @@ }, "entity_component": { "_": { + "name": "[%key:component::humidifier::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "dehumidifier": { + "name": "Dehumidifier" + }, + "humidifier": { + "name": "[%key:component::humidifier::entity_component::_::name%]" } } } diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index 509799b5ed34..8294d7287539 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -2,6 +2,7 @@ "title": "Input boolean", "entity_component": { "_": { + "name": "[%key:component::input_boolean::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 38f843ab1dc4..ef3fa74bd98c 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -21,6 +21,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::light::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index e6c9884457a1..497dec1a4045 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -17,6 +17,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::lock::title%]", "state": { "jammed": "Jammed", "locked": "[%key:common::state::locked%]", diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 2c8f3d3d2bd8..8627a31307f0 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -21,6 +21,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::media_player::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", @@ -30,6 +31,15 @@ "standby": "[%key:common::state::standby%]", "buffering": "Buffering" } + }, + "tv": { + "name": "TV" + }, + "speaker": { + "name": "Speaker" + }, + "receiver": { + "name": "Receiver" } } } diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 77ba7e7a913c..d265b84c740e 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -4,5 +4,136 @@ "action_type": { "set_value": "Set value for {entity_name}" } + }, + "entity_component": { + "_": { + "name": "[%key:component::number::title%]" + }, + "apparent_power": { + "name": "[%key:component::sensor::entity_component::apparent_power::name%]" + }, + "aqi": { + "name": "[%key:component::sensor::entity_component::aqi::name%]" + }, + "atmospheric_pressure": { + "name": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]" + }, + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]" + }, + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "current": { + "name": "[%key:component::sensor::entity_component::current::name%]" + }, + "data_rate": { + "name": "[%key:component::sensor::entity_component::data_rate::name%]" + }, + "distance": { + "name": "[%key:component::sensor::entity_component::distance::name%]" + }, + "energy": { + "name": "[%key:component::sensor::entity_component::energy::name%]" + }, + "energy_storage": { + "name": "[%key:component::sensor::entity_component::energy_storage::name%]" + }, + "frequency": { + "name": "[%key:component::sensor::entity_component::frequency::name%]" + }, + "gas": { + "name": "[%key:component::sensor::entity_component::gas::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "illuminance": { + "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "irradiance": { + "name": "[%key:component::sensor::entity_component::irradiance::name%]" + }, + "moisture": { + "name": "[%key:component::sensor::entity_component::moisture::name%]" + }, + "nitrogen_dioxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, + "nitrogen_monoxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]" + }, + "nitrous_oxide": { + "name": "[%key:component::sensor::entity_component::nitrous_oxide::name%]" + }, + "ozone": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + }, + "pm1": { + "name": "[%key:component::sensor::entity_component::pm1::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "power_factor": { + "name": "[%key:component::sensor::entity_component::power_factor::name%]" + }, + "power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "precipitation": { + "name": "[%key:component::sensor::entity_component::precipitation::name%]" + }, + "precipitation_intensity": { + "name": "[%key:component::sensor::entity_component::precipitation_intensity::name%]" + }, + "pressure": { + "name": "[%key:component::sensor::entity_component::pressure::name%]" + }, + "reactive_power": { + "name": "[%key:component::sensor::entity_component::reactive_power::name%]" + }, + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + }, + "sound_pressure": { + "name": "[%key:component::sensor::entity_component::sound_pressure::name%]" + }, + "speed": { + "name": "[%key:component::sensor::entity_component::speed::name%]" + }, + "sulphur_dioxide": { + "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "volatile_organic_compounds": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, + "volume": { + "name": "[%key:component::sensor::entity_component::volume::name%]" + }, + "volume_storage": { + "name": "[%key:component::sensor::entity_component::volume_storage::name%]" + }, + "water": { + "name": "[%key:component::sensor::entity_component::water::name%]" + }, + "weight": { + "name": "[%key:component::sensor::entity_component::weight::name%]" + }, + "wind_speed": { + "name": "[%key:component::sensor::entity_component::wind_speed::name%]" + } } } diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index 7bba0198a141..8ee8c3a56a24 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -2,6 +2,7 @@ "title": "Person", "entity_component": { "_": { + "name": "[%key:component::person::title%]", "state": { "home": "[%key:common::state::home%]", "not_home": "[%key:common::state::not_home%]" diff --git a/homeassistant/components/plant/strings.json b/homeassistant/components/plant/strings.json index 5ece766c71ac..853e5daee1f4 100644 --- a/homeassistant/components/plant/strings.json +++ b/homeassistant/components/plant/strings.json @@ -2,6 +2,7 @@ "title": "Plant Monitor", "entity_component": { "_": { + "name": "[%key:component::plant::title%]", "state": { "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index a558cc76fe07..f0d2787b6586 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -18,6 +18,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::remote::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index ecc673805c20..f8da366887ac 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -2,6 +2,7 @@ "title": "Schedule", "entity_component": { "_": { + "name": "[%key:component::schedule::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index a4ea0860067e..c78e4265cbd7 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -2,6 +2,7 @@ "title": "Script", "entity_component": { "_": { + "name": "[%key:component::script::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 4b764c609c09..a579be672054 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -96,10 +96,149 @@ }, "entity_component": { "_": { + "name": "[%key:component::sensor::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "date": { + "name": "Date" + }, + "duration": { + "name": "Duration" + }, + "apparent_power": { + "name": "Apparent power" + }, + "aqi": { + "name": "Air quality index" + }, + "atmospheric_pressure": { + "name": "Atmospheric pressure" + }, + "battery": { + "name": "Battery" + }, + "carbon_monoxide": { + "name": "Carbon monoxide" + }, + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "current": { + "name": "Current" + }, + "data_rate": { + "name": "Data rate" + }, + "data_size": { + "name": "Data size" + }, + "distance": { + "name": "Distance" + }, + "energy": { + "name": "Energy" + }, + "energy_storage": { + "name": "Stored energy" + }, + "frequency": { + "name": "Frequency" + }, + "gas": { + "name": "Gas" + }, + "humidity": { + "name": "Humidity" + }, + "illuminance": { + "name": "Illuminance" + }, + "irradiance": { + "name": "Irradiance" + }, + "moisture": { + "name": "Moisture" + }, + "monetary": { + "name": "Balance" + }, + "nitrogen_dioxide": { + "name": "Nitrogen dioxide" + }, + "nitrogen_monoxide": { + "name": "Nitrogen monoxide" + }, + "nitrous_oxide": { + "name": "Nitrous oxide" + }, + "ozone": { + "name": "Ozone" + }, + "pm1": { + "name": "Particulate matter 0.1 μm" + }, + "pm10": { + "name": "Particulate matter 10 μm" + }, + "pm25": { + "name": "Particulate matter 2.5 μm" + }, + "power_factor": { + "name": "Power factor" + }, + "power": { + "name": "Power" + }, + "precipitation": { + "name": "Precipitation" + }, + "precipitation_intensity": { + "name": "Precipitation intensity" + }, + "pressure": { + "name": "Pressure" + }, + "reactive_power": { + "name": "Reactive power" + }, + "signal_strength": { + "name": "Signal strength" + }, + "sound_pressure": { + "name": "Sound pressure" + }, + "speed": { + "name": "Speed" + }, + "sulphur_dioxide": { + "name": "Sulphur dioxide" + }, + "temperature": { + "name": "Temperature" + }, + "volatile_organic_compounds": { + "name": "VOCs" + }, + "voltage": { + "name": "Voltage" + }, + "volume": { + "name": "Volume" + }, + "volume_storage": { + "name": "Stored volume" + }, + "water": { + "name": "Water" + }, + "weight": { + "name": "Weight" + }, + "wind_speed": { + "name": "Wind speed" } } } diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index d8a75224f62a..9a49a061c1fa 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -12,6 +12,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::sun::title%]", "state": { "above_horizon": "Above horizon", "below_horizon": "Below horizon" diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index ba7c5c6848f5..a7934ba42092 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -18,10 +18,17 @@ }, "entity_component": { "_": { + "name": "[%key:component::switch::title%]", "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } + }, + "switch": { + "name": "[%key:component::switch::entity_component::_::name%]" + }, + "outlet": { + "name": "Outlet" } } } diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 914ee738354f..b6dd2418ada2 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,6 +1,7 @@ { "entity_component": { "_": { + "name": "Timer", "state": { "active": "[%key:common::state::active%]", "idle": "[%key:common::state::idle%]", diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index c26d3968ae1a..776c2c59a3a7 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -6,5 +6,13 @@ "turned_on": "{entity_name} got an update available", "turned_off": "{entity_name} became up-to-date" } + }, + "entity_component": { + "_": { + "name": "[%key:component::update::title%]" + }, + "firmware": { + "name": "firmware" + } } } diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index eb84b910b454..a27a60bba4f3 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -16,6 +16,7 @@ }, "entity_component": { "_": { + "name": "[%key:component::vacuum::title%]", "state": { "cleaning": "Cleaning", "docked": "Docked", diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 9e3eec86041a..6344b5a847a2 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -7,6 +7,7 @@ }, "entity_component": { "_": { + "name": "Water heater", "state": { "off": "[%key:common::state::off%]", "eco": "Eco", diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index b3af53a91c4a..0f88f1ae7e2e 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -1,6 +1,7 @@ { "entity_component": { "_": { + "name": "Weather", "state": { "clear-night": "Clear, night", "cloudy": "Cloudy", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index eb51d12c3745..bf2697644daf 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -266,6 +266,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: }, vol.Optional("entity_component"): cv.schema_with_slug_keys( { + vol.Optional("name"): str, vol.Optional("state"): cv.schema_with_slug_keys( cv.string_with_no_html, slug_validator=translation_key_validator, diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 197053ba2b8d..6f5b42532185 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -383,22 +383,25 @@ async def test_caching(hass: HomeAssistant) -> None: for key in load1: assert key.startswith( - "component.sensor.entity_component._.state." - ) or key.startswith("component.light.entity_component._.state.") + ( + "component.sensor.entity_component.", + "component.light.entity_component.", + ) + ) load_sensor_only = await translation.async_get_translations( hass, "en", "entity_component", integrations={"sensor"} ) assert load_sensor_only for key in load_sensor_only: - assert key.startswith("component.sensor.entity_component._.state.") + assert key.startswith("component.sensor.entity_component.") load_light_only = await translation.async_get_translations( hass, "en", "entity_component", integrations={"light"} ) assert load_light_only for key in load_light_only: - assert key.startswith("component.light.entity_component._.state.") + assert key.startswith("component.light.entity_component.") hass.config.components.add("media_player") From 9893b1cf4ae6b878791cf0cbeedf64120f5794cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 22:03:06 +0100 Subject: [PATCH 0546/1058] Cleanup get_local_ip in global conftest (#89826) --- tests/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c4530bd5381e..08ca75829c30 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,7 +31,7 @@ import pytest_socket import requests_mock from syrupy.assertion import SnapshotAssertion -from homeassistant import core as ha, loader, runner, util +from homeassistant import core as ha, loader, runner from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant, legacy_api_password @@ -228,7 +228,6 @@ def check_real(func: Callable[_P, Coroutine[Any, Any, _R]]): # Guard a few functions that would make network connections location.async_detect_location_info = check_real(location.async_detect_location_info) -util.get_local_ip = lambda: "127.0.0.1" @pytest.fixture(name="caplog") From 81c0382e4b38d6d0045766684c2a0fa6ffb28ad6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Mar 2023 22:20:27 +0100 Subject: [PATCH 0547/1058] Fix lingering timer in bootstrap tests (#89790) * Fix lingering timer in bootstrap test * Adjust comment * Use a constant --- homeassistant/components/cloud/__init__.py | 3 ++- tests/test_bootstrap.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index e9b852ada8dd..8352b566afe9 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -68,6 +68,7 @@ SERVICE_REMOTE_DISCONNECT = "remote_disconnect" SIGNAL_CLOUD_CONNECTION_STATE = "CLOUD_CONNECTION_STATE" +STARTUP_REPAIR_DELAY = 1 # 1 hour ALEXA_ENTITY_SCHEMA = vol.Schema( { @@ -309,7 +310,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_call_later( hass=hass, - delay=timedelta(hours=1), + delay=timedelta(hours=STARTUP_REPAIR_DELAY), action=async_startup_repairs, ) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 1ee60b71dada..9f02d6394e08 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -551,6 +551,7 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( assert "Waiting on integrations to complete setup" in caplog.text +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_hass_invalid_yaml( mock_enable_logging: Mock, mock_is_virtual_env: Mock, @@ -606,6 +607,7 @@ async def test_setup_hass_config_dir_nonexistent( ) +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_hass_safe_mode( mock_enable_logging: Mock, mock_is_virtual_env: Mock, @@ -640,6 +642,7 @@ async def test_setup_hass_safe_mode( @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_hass_invalid_core_config( mock_hass_config: None, mock_enable_logging: Mock, @@ -678,6 +681,7 @@ async def test_setup_hass_invalid_core_config( } ], ) +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_setup_safe_mode_if_no_frontend( mock_hass_config: None, mock_enable_logging: Mock, From e16f17f5a8c4c6ee86475272a32170bddaf631e6 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 16 Mar 2023 19:42:26 -0500 Subject: [PATCH 0548/1058] Voice assistant integration with pipelines (#89822) * Initial commit * Add websocket test tool * Small tweak * Tiny cleanup * Make pipeline work with frontend branch * Add some more info to start event * Fixes * First voice assistant tests * Remove run_task * Clean up for PR * Add config_flow.py * Remove CLI tool * Simplify by removing stt/tts for now * Clean up and fix tests * More clean up and API changes * Add quality_scale * Remove data from run-finish * Use StrEnum backport --------- Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 2 + .../components/voice_assistant/__init__.py | 23 +++ .../components/voice_assistant/const.py | 3 + .../components/voice_assistant/manifest.json | 9 ++ .../components/voice_assistant/pipeline.py | 124 ++++++++++++++ .../voice_assistant/websocket_api.py | 67 ++++++++ .../components/websocket_api/connection.py | 5 + homeassistant/generated/integrations.json | 6 + tests/components/voice_assistant/__init__.py | 1 + .../voice_assistant/test_websocket.py | 152 ++++++++++++++++++ 10 files changed, 392 insertions(+) create mode 100644 homeassistant/components/voice_assistant/__init__.py create mode 100644 homeassistant/components/voice_assistant/const.py create mode 100644 homeassistant/components/voice_assistant/manifest.json create mode 100644 homeassistant/components/voice_assistant/pipeline.py create mode 100644 homeassistant/components/voice_assistant/websocket_api.py create mode 100644 tests/components/voice_assistant/__init__.py create mode 100644 tests/components/voice_assistant/test_websocket.py diff --git a/CODEOWNERS b/CODEOWNERS index f95d89fec476..afab5f88856e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1309,6 +1309,8 @@ build.json @home-assistant/supervisor /tests/components/vizio/ @raman325 /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare +/homeassistant/components/voice_assistant/ @balloob @synesthesiam +/tests/components/voice_assistant/ @balloob @synesthesiam /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos diff --git a/homeassistant/components/voice_assistant/__init__.py b/homeassistant/components/voice_assistant/__init__.py new file mode 100644 index 000000000000..d06176847e96 --- /dev/null +++ b/homeassistant/components/voice_assistant/__init__.py @@ -0,0 +1,23 @@ +"""The Voice Assistant integration.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import DEFAULT_PIPELINE, DOMAIN +from .pipeline import Pipeline +from .websocket_api import async_register_websocket_api + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Voice Assistant integration.""" + hass.data[DOMAIN] = { + DEFAULT_PIPELINE: Pipeline( + name=DEFAULT_PIPELINE, + language=None, + conversation_engine=None, + ) + } + async_register_websocket_api(hass) + + return True diff --git a/homeassistant/components/voice_assistant/const.py b/homeassistant/components/voice_assistant/const.py new file mode 100644 index 000000000000..86572fb459f2 --- /dev/null +++ b/homeassistant/components/voice_assistant/const.py @@ -0,0 +1,3 @@ +"""Constants for the Voice Assistant integration.""" +DOMAIN = "voice_assistant" +DEFAULT_PIPELINE = "default" diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json new file mode 100644 index 000000000000..6d353660b31d --- /dev/null +++ b/homeassistant/components/voice_assistant/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "voice_assistant", + "name": "Voice Assistant", + "codeowners": ["@balloob", "@synesthesiam"], + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/voice_assistant", + "iot_class": "local_push", + "quality_scale": "internal" +} diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py new file mode 100644 index 000000000000..8c7d22981abe --- /dev/null +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -0,0 +1,124 @@ +"""Classes for voice assistant pipelines.""" +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from homeassistant.backports.enum import StrEnum +from homeassistant.components import conversation +from homeassistant.core import Context, HomeAssistant +from homeassistant.util.dt import utcnow + +DEFAULT_TIMEOUT = 30 # seconds + + +@dataclass +class PipelineRequest: + """Request to start a pipeline run.""" + + intent_input: str + conversation_id: str | None = None + + +class PipelineEventType(StrEnum): + """Event types emitted during a pipeline run.""" + + RUN_START = "run-start" + RUN_FINISH = "run-finish" + INTENT_START = "intent-start" + INTENT_FINISH = "intent-finish" + ERROR = "error" + + +@dataclass +class PipelineEvent: + """Events emitted during a pipeline run.""" + + type: PipelineEventType + data: dict[str, Any] | None = None + timestamp: str = field(default_factory=lambda: utcnow().isoformat()) + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event.""" + return { + "type": self.type, + "timestamp": self.timestamp, + "data": self.data or {}, + } + + +@dataclass +class Pipeline: + """A voice assistant pipeline.""" + + name: str + language: str | None + conversation_engine: str | None + + async def run( + self, + hass: HomeAssistant, + context: Context, + request: PipelineRequest, + event_callback: Callable[[PipelineEvent], None], + timeout: int | float | None = DEFAULT_TIMEOUT, + ) -> None: + """Run a pipeline with an optional timeout.""" + await asyncio.wait_for( + self._run(hass, context, request, event_callback), timeout=timeout + ) + + async def _run( + self, + hass: HomeAssistant, + context: Context, + request: PipelineRequest, + event_callback: Callable[[PipelineEvent], None], + ) -> None: + """Run a pipeline.""" + language = self.language or hass.config.language + event_callback( + PipelineEvent( + PipelineEventType.RUN_START, + { + "pipeline": self.name, + "language": language, + }, + ) + ) + + intent_input = request.intent_input + + event_callback( + PipelineEvent( + PipelineEventType.INTENT_START, + { + "engine": self.conversation_engine or "default", + "intent_input": intent_input, + }, + ) + ) + + conversation_result = await conversation.async_converse( + hass=hass, + text=intent_input, + conversation_id=request.conversation_id, + context=context, + language=language, + agent_id=self.conversation_engine, + ) + + event_callback( + PipelineEvent( + PipelineEventType.INTENT_FINISH, + {"intent_output": conversation_result.as_dict()}, + ) + ) + + event_callback( + PipelineEvent( + PipelineEventType.RUN_FINISH, + ) + ) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py new file mode 100644 index 000000000000..4ea88c3da00c --- /dev/null +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -0,0 +1,67 @@ +"""Voice Assistant Websocket API.""" +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .pipeline import DEFAULT_TIMEOUT, PipelineRequest + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_run) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "voice_assistant/run", + vol.Optional("pipeline", default="default"): str, + vol.Required("intent_input"): str, + vol.Optional("conversation_id"): vol.Any(str, None), + vol.Optional("timeout"): vol.Any(float, int), + } +) +@websocket_api.async_response +async def websocket_run( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Run a pipeline.""" + pipeline_id = msg["pipeline"] + pipeline = hass.data[DOMAIN].get(pipeline_id) + if pipeline is None: + connection.send_error( + msg["id"], "pipeline_not_found", f"Pipeline not found: {pipeline_id}" + ) + return + + # Run pipeline with a timeout. + # Events are sent over the websocket connection. + timeout = msg.get("timeout", DEFAULT_TIMEOUT) + run_task = hass.async_create_task( + pipeline.run( + hass, + connection.context(msg), + request=PipelineRequest( + intent_input=msg["intent_input"], + conversation_id=msg.get("conversation_id"), + ), + event_callback=lambda event: connection.send_event( + msg["id"], event.as_dict() + ), + timeout=timeout, + ) + ) + + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel + + connection.send_result(msg["id"]) + + # Task contains a timeout + await run_task diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 90c7e9906e4e..08d053145215 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -65,6 +65,11 @@ class ActiveConnection: """Send a result message.""" self.send_message(messages.result_message(msg_id, result)) + @callback + def send_event(self, msg_id: int, event: Any | None = None) -> None: + """Send a event message.""" + self.send_message(messages.event_message(msg_id, event)) + @callback def send_error(self, msg_id: int, code: str, message: str) -> None: """Send a error message.""" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4adde5b2449d..8dbdd5a7851e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6068,6 +6068,12 @@ } } }, + "voice_assistant": { + "name": "Voice Assistant", + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_push" + }, "voicerss": { "name": "VoiceRSS", "integration_type": "hub", diff --git a/tests/components/voice_assistant/__init__.py b/tests/components/voice_assistant/__init__.py new file mode 100644 index 000000000000..6838f353c4bb --- /dev/null +++ b/tests/components/voice_assistant/__init__.py @@ -0,0 +1 @@ +"""Tests for the Voice Assistant integration.""" diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py new file mode 100644 index 000000000000..e862da6f5420 --- /dev/null +++ b/tests/components/voice_assistant/test_websocket.py @@ -0,0 +1,152 @@ +"""Websocket tests for Voice Assistant integration.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def init_components(hass): + """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "voice_assistant", {}) + + +async def test_text_only_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with text input (no STT/TTS).""" + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 5, "type": "voice_assistant/run", "intent_input": "Are the lights on?"} + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": "default", + "language": hass.config.language, + } + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": "Are the lights on?", + } + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-finish" + assert msg["event"]["data"] == { + "intent_output": { + "response": { + "speech": { + "plain": { + "speech": "Sorry, I couldn't understand that", + "extra_data": None, + } + }, + "card": {}, + "language": "en", + "response_type": "error", + "data": {"code": "no_intent_match"}, + }, + "conversation_id": None, + } + } + + # run finish + msg = await client.receive_json() + assert msg["event"]["type"] == "run-finish" + assert msg["event"]["data"] == {} + + +async def test_conversation_timeout( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test partial pipeline run with conversation agent timeout.""" + client = await hass_ws_client(hass) + + async def sleepy_converse(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.conversation.async_converse", new=sleepy_converse + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "intent_input": "Are the lights on?", + "timeout": 0.00001, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": "default", + "language": hass.config.language, + } + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": "Are the lights on?", + } + + # timeout error + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "timeout" + + +async def test_pipeline_timeout( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test pipeline run with immediate timeout.""" + client = await hass_ws_client(hass) + + async def sleepy_run(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.voice_assistant.pipeline.Pipeline._run", + new=sleepy_run, + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "intent_input": "Are the lights on?", + "timeout": 0.0001, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # timeout error + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "timeout" From ff8b91aeea5bbb9709164d4218a110ef42bd87b1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 03:39:41 +0100 Subject: [PATCH 0549/1058] Add freezer to known test fixtures in pylint (#89825) Add freezer to known fixtures in pylint --- pylint/plugins/hass_enforce_type_hints.py | 1 + tests/components/calendar/test_trigger.py | 3 +- tests/components/demo/test_sensor.py | 9 ++++-- .../devolo_home_control/test_init.py | 2 +- .../components/hardware/test_websocket_api.py | 5 +++- .../homeassistant_alerts/test_init.py | 3 +- tests/components/mqtt/test_binary_sensor.py | 5 ++-- tests/components/mqtt/test_sensor.py | 5 ++-- tests/components/onewire/test_init.py | 2 +- tests/components/recorder/test_init.py | 3 +- tests/components/repairs/test_init.py | 5 +++- tests/components/sql/test_util.py | 10 +++---- .../components/template/test_binary_sensor.py | 10 +++++-- tests/components/tod/test_binary_sensor.py | 29 ++++++++++++------- 14 files changed, 61 insertions(+), 31 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 3e390e2810f1..4092beb3da1e 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -104,6 +104,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "enable_statistics": "bool", "enable_statistics_table_validation": "bool", "entity_registry": "EntityRegistry", + "freezer": "FrozenDateTimeFactory", "hass_access_token": "str", "hass_admin_credential": "Credentials", "hass_admin_user": "MockUser", diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index a5bba8794d16..7885a4524cf1 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -15,6 +15,7 @@ import secrets from typing import Any from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import calendar @@ -642,7 +643,7 @@ async def test_event_payload( async def test_trigger_timestamp_window_edge( - hass: HomeAssistant, calls, fake_schedule, freezer + hass: HomeAssistant, calls, fake_schedule, 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") diff --git a/tests/components/demo/test_sensor.py b/tests/components/demo/test_sensor.py index 3d96e9f93412..71c212694c44 100644 --- a/tests/components/demo/test_sensor.py +++ b/tests/components/demo/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the demo sensor component.""" from datetime import timedelta +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import core as ha @@ -13,7 +14,9 @@ from tests.common import mock_restore_cache_with_extra_data @pytest.mark.parametrize(("entity_id", "delta"), (("sensor.total_energy_kwh", 0.5),)) -async def test_energy_sensor(hass: HomeAssistant, entity_id, delta, freezer) -> None: +async def test_energy_sensor( + hass: HomeAssistant, entity_id, delta, freezer: FrozenDateTimeFactory +) -> None: """Test energy sensors increase periodically.""" assert await async_setup_component( hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: {"platform": DOMAIN}} @@ -32,7 +35,9 @@ async def test_energy_sensor(hass: HomeAssistant, entity_id, delta, freezer) -> @pytest.mark.parametrize(("entity_id", "delta"), (("sensor.total_energy_kwh", 0.5),)) -async def test_restore_state(hass: HomeAssistant, entity_id, delta, freezer) -> None: +async def test_restore_state( + hass: HomeAssistant, entity_id, delta, freezer: FrozenDateTimeFactory +) -> None: """Test energy sensors restore state.""" fake_state = ha.State( entity_id, diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 0eb011d5ef2f..29572f2ece40 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -65,7 +65,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: async def test_remove_device( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, -): +) -> None: """Test removing a device.""" assert await async_setup_component(hass, "config", {}) entry = configure_integration(hass) diff --git a/tests/components/hardware/test_websocket_api.py b/tests/components/hardware/test_websocket_api.py index b3a3e9ba1146..98fa00486ffa 100644 --- a/tests/components/hardware/test_websocket_api.py +++ b/tests/components/hardware/test_websocket_api.py @@ -3,6 +3,7 @@ from collections import namedtuple import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import psutil_home_assistant as ha_psutil from homeassistant.components.hardware.const import DOMAIN @@ -33,7 +34,9 @@ TEST_TIME_ADVANCE_INTERVAL = datetime.timedelta(seconds=5 + 1) async def test_system_status_subscription( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test websocket system status subscription.""" diff --git a/tests/components/homeassistant_alerts/test_init.py b/tests/components/homeassistant_alerts/test_init.py index 36f0cad75882..7f060b09cf9c 100644 --- a/tests/components/homeassistant_alerts/test_init.py +++ b/tests/components/homeassistant_alerts/test_init.py @@ -5,6 +5,7 @@ from datetime import timedelta import json from unittest.mock import ANY, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.homeassistant_alerts import ( @@ -291,7 +292,7 @@ async def test_alerts_refreshed_on_component_load( late_components, initial_alerts, late_alerts, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test alerts are refreshed when components are loaded.""" diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index a088d2ac6412..73176de9edd7 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -5,6 +5,7 @@ import json from pathlib import Path from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import binary_sensor, mqtt @@ -1088,7 +1089,7 @@ async def test_cleanup_triggers_and_restoring_state( mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, - freezer, + freezer: FrozenDateTimeFactory, payload1, state1, payload2, @@ -1147,7 +1148,7 @@ async def test_cleanup_triggers_and_restoring_state( async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index b40c2f43d04d..c1bc07f77aaa 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -5,6 +5,7 @@ import json from pathlib import Path from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import mqtt, sensor @@ -1278,7 +1279,7 @@ async def test_cleanup_triggers_and_restoring_state( mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test cleanup old triggers at reloading and restoring the state.""" domain = sensor.DOMAIN @@ -1338,7 +1339,7 @@ async def test_cleanup_triggers_and_restoring_state( async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - freezer, + freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 9382b95521cc..5a69fb95e165 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -80,7 +80,7 @@ async def test_registry_cleanup( config_entry: ConfigEntry, owproxy: MagicMock, hass_ws_client: WebSocketGenerator, -): +) -> None: """Test being able to remove a disconnected device.""" assert await async_setup_component(hass, "config", {}) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index ed804087d8ae..e3b9145dc8bd 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -10,6 +10,7 @@ import threading from typing import cast from unittest.mock import Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from sqlalchemy.exc import DatabaseError, OperationalError, SQLAlchemyError @@ -1219,7 +1220,7 @@ def test_statistics_runs_initiated(hass_recorder: Callable[..., HomeAssistant]) @pytest.mark.freeze_time("2022-09-13 09:00:00+02:00") -def test_compile_missing_statistics(tmpdir, freezer) -> None: +def test_compile_missing_statistics(tmpdir, freezer: FrozenDateTimeFactory) -> None: """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index 87acc96db230..bae71e71e2ef 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, Mock from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.repairs import repairs_flow_manager @@ -335,7 +336,9 @@ async def test_ignore_issue( async def test_delete_issue( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test we can delete an issue.""" freezer.move_to("2022-07-19 07:53:05") diff --git a/tests/components/sql/test_util.py b/tests/components/sql/test_util.py index 31adbe076ebb..5211a47c4d41 100644 --- a/tests/components/sql/test_util.py +++ b/tests/components/sql/test_util.py @@ -1,15 +1,13 @@ """Test the sql utils.""" -from unittest.mock import AsyncMock - -from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder import Recorder, get_instance from homeassistant.components.sql.util import resolve_db_url from homeassistant.core import HomeAssistant async def test_resolve_db_url_when_none_configured( - recorder_mock: AsyncMock, + recorder_mock: Recorder, hass: HomeAssistant, -): +) -> None: """Test return recorder db_url if provided db_url is None.""" db_url = None resolved_url = resolve_db_url(hass, db_url) @@ -17,7 +15,7 @@ async def test_resolve_db_url_when_none_configured( assert resolved_url == get_instance(hass).db_url -async def test_resolve_db_url_when_configured(hass: HomeAssistant): +async def test_resolve_db_url_when_configured(hass: HomeAssistant) -> None: """Test return provided db_url if it's set.""" db_url = "mssql://" resolved_url = resolve_db_url(hass, db_url) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 6483524545d9..1e6b2cc3840b 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta, timezone import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import setup @@ -1257,7 +1258,12 @@ async def test_trigger_entity_restore_state( ) @pytest.mark.parametrize("restored_state", [ON, OFF]) async def test_trigger_entity_restore_state_auto_off( - hass: HomeAssistant, count, domain, config, restored_state, freezer + hass: HomeAssistant, + count, + domain, + config, + restored_state, + freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" @@ -1317,7 +1323,7 @@ async def test_trigger_entity_restore_state_auto_off( ], ) async def test_trigger_entity_restore_state_auto_off_expired( - hass: HomeAssistant, count, domain, config, freezer + hass: HomeAssistant, count, domain, config, freezer: FrozenDateTimeFactory ) -> None: """Test restoring trigger template binary sensor.""" diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index 23b76250164b..0f0a1456459b 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.const import STATE_OFF, STATE_ON @@ -104,7 +105,7 @@ async def test_midnight_turnover_before_midnight_inside_period( async def test_midnight_turnover_after_midnight_inside_period( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test midnight turnover setting before midnight inside period .""" test_time = datetime(2019, 1, 10, 21, 0, 0, tzinfo=hass_tz_info) @@ -163,7 +164,7 @@ async def test_after_happens_tomorrow(hass: HomeAssistant) -> None: async def test_midnight_turnover_after_midnight_outside_period( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test midnight turnover setting before midnight inside period .""" test_time = datetime(2019, 1, 10, 20, 0, 0, tzinfo=hass_tz_info) @@ -197,7 +198,7 @@ async def test_midnight_turnover_after_midnight_outside_period( async def test_from_sunrise_to_sunset( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test period from sunrise to sunset.""" test_time = datetime(2019, 1, 12, tzinfo=hass_tz_info) @@ -256,7 +257,7 @@ async def test_from_sunrise_to_sunset( async def test_from_sunset_to_sunrise( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test period from sunset to sunrise.""" test_time = datetime(2019, 1, 12, tzinfo=hass_tz_info) @@ -311,7 +312,9 @@ async def test_from_sunset_to_sunrise( assert state.state == STATE_OFF -async def test_offset(hass: HomeAssistant, freezer, hass_tz_info) -> None: +async def test_offset( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test offset.""" after = datetime(2019, 1, 10, 18, 0, 0, tzinfo=hass_tz_info) + timedelta( hours=1, minutes=34 @@ -365,7 +368,9 @@ async def test_offset(hass: HomeAssistant, freezer, hass_tz_info) -> None: assert state.state == STATE_OFF -async def test_offset_overnight(hass: HomeAssistant, freezer, hass_tz_info) -> None: +async def test_offset_overnight( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test offset overnight.""" after = datetime(2019, 1, 10, 18, 0, 0, tzinfo=hass_tz_info) + timedelta( hours=1, minutes=34 @@ -397,7 +402,7 @@ async def test_offset_overnight(hass: HomeAssistant, freezer, hass_tz_info) -> N async def test_norwegian_case_winter( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test location in Norway where the sun doesn't set in summer.""" hass.config.latitude = 69.6 @@ -465,7 +470,7 @@ async def test_norwegian_case_winter( async def test_norwegian_case_summer( - hass: HomeAssistant, freezer, hass_tz_info + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info ) -> None: """Test location in Norway where the sun doesn't set in summer.""" hass.config.latitude = 69.6 @@ -534,7 +539,9 @@ async def test_norwegian_case_summer( assert state.state == STATE_OFF -async def test_sun_offset(hass: HomeAssistant, freezer, hass_tz_info) -> None: +async def test_sun_offset( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test sun event with offset.""" test_time = datetime(2019, 1, 12, tzinfo=hass_tz_info) sunrise = dt_util.as_local( @@ -608,7 +615,9 @@ async def test_sun_offset(hass: HomeAssistant, freezer, hass_tz_info) -> None: assert state.state == STATE_ON -async def test_dst(hass: HomeAssistant, freezer, hass_tz_info) -> None: +async def test_dst( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, hass_tz_info +) -> None: """Test sun event with offset.""" hass.config.time_zone = "CET" dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) From 350e967a894b93a509658618d6c1955e05e00eb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 16:44:49 -1000 Subject: [PATCH 0550/1058] Bump aioharmony to 0.2.10 (#89831) fixes #89823 --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 2603ee613ae4..c6a6327046d1 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -13,7 +13,7 @@ "documentation": "https://www.home-assistant.io/integrations/harmony", "iot_class": "local_push", "loggers": ["aioharmony", "slixmpp"], - "requirements": ["aioharmony==0.2.9"], + "requirements": ["aioharmony==0.2.10"], "ssdp": [ { "manufacturer": "Logitech", diff --git a/requirements_all.txt b/requirements_all.txt index 6c7fbf82bcde..5d1a0b634d39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -171,7 +171,7 @@ aiogithubapi==22.10.1 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.9 +aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==2.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53477f0912a1..ffca3813dd5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aiogithubapi==22.10.1 aioguardian==2022.07.0 # homeassistant.components.harmony -aioharmony==0.2.9 +aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==2.6.1 From f8da3ee50edfc5247e2cd48e61b2473b38317127 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 04:00:45 +0100 Subject: [PATCH 0551/1058] Add state attribute translations for locks (#89820) --- homeassistant/components/lock/strings.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index 497dec1a4045..da4b5217b862 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -24,6 +24,14 @@ "locking": "Locking", "unlocked": "[%key:common::state::unlocked%]", "unlocking": "Unlocking" + }, + "state_attributes": { + "code_format": { + "name": "[%key:component::alarm_control_panel::entity_component::_::state_attributes::code_format::name%]" + }, + "changed_by": { + "name": "[%key:component::alarm_control_panel::entity_component::_::state_attributes::changed_by::name%]" + } } } } From 79c9447770a1cfcbdf3643e3ab9ea633f52887d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 04:01:23 +0100 Subject: [PATCH 0552/1058] Fix lingering timer in event helper tests (#89819) Fix lingering timer in event tests --- tests/helpers/test_event.py | 40 ++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index afc960f4e198..211663babc8b 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -935,7 +935,7 @@ async def test_track_template_time_change( with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): - async_track_template(hass, template_error, error_callback) + unsub = async_track_template(hass, template_error, error_callback) await hass.async_block_till_done() assert not calls @@ -947,6 +947,8 @@ async def test_track_template_time_change( assert len(calls) == 1 assert calls[0] == (None, None, None) + unsub() + async def test_track_template_result(hass: HomeAssistant) -> None: """Test tracking template.""" @@ -1437,7 +1439,7 @@ async def test_track_template_result_super_template_2( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1459,7 +1461,7 @@ async def test_track_template_result_super_template_2( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info2 = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1480,7 +1482,7 @@ async def test_track_template_result_super_template_2( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info3 = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1530,6 +1532,10 @@ async def test_track_template_result_super_template_2( assert wildcard_runs == [(0, 5), (5, 30)] assert wildercard_runs == [(0, 10), (10, 35)] + info.async_remove() + info2.async_remove() + info3.async_remove() + @pytest.mark.parametrize( "availability_template", @@ -1579,7 +1585,7 @@ async def test_track_template_result_super_template_2_initially_false( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1601,7 +1607,7 @@ async def test_track_template_result_super_template_2_initially_false( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info2 = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1622,7 +1628,7 @@ async def test_track_template_result_super_template_2_initially_false( _super_template_as_boolean(track_result.result) ) - async_track_template_result( + info3 = async_track_template_result( hass, [ TrackTemplate(template_availability, None), @@ -1669,6 +1675,10 @@ async def test_track_template_result_super_template_2_initially_false( assert wildcard_runs == [(0, 5), (5, 30)] assert wildercard_runs == [(0, 10), (10, 35)] + info.async_remove() + info2.async_remove() + info3.async_remove() + async def test_track_template_result_complex(hass: HomeAssistant) -> None: """Test tracking template.""" @@ -2351,6 +2361,8 @@ async def test_track_template_rate_limit(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [0, 1, 2, 4] + info.async_remove() + async def test_track_template_rate_limit_super(hass: HomeAssistant) -> None: """Test template rate limit with super template.""" @@ -2423,6 +2435,8 @@ async def test_track_template_rate_limit_super(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [0, 1, 4] + info.async_remove() + async def test_track_template_rate_limit_super_2(hass: HomeAssistant) -> None: """Test template rate limit with rate limited super template.""" @@ -2490,6 +2504,8 @@ async def test_track_template_rate_limit_super_2(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [1, 5] + info.async_remove() + async def test_track_template_rate_limit_super_3(hass: HomeAssistant) -> None: """Test template with rate limited super template.""" @@ -2562,6 +2578,8 @@ async def test_track_template_rate_limit_super_3(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [1, 2, 5, 6, 7] + info.async_remove() + async def test_track_template_rate_limit_suppress_listener(hass: HomeAssistant) -> None: """Test template rate limit will suppress the listener during the rate limit.""" @@ -2657,6 +2675,8 @@ async def test_track_template_rate_limit_suppress_listener(hass: HomeAssistant) } assert refresh_runs == [0, 1, 2, 4] + info.async_remove() + async def test_track_template_rate_limit_five(hass: HomeAssistant) -> None: """Test template rate limit of 5 seconds.""" @@ -2690,6 +2710,8 @@ async def test_track_template_rate_limit_five(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert refresh_runs == [0, 1] + info.async_remove() + async def test_track_template_has_default_rate_limit(hass: HomeAssistant) -> None: """Test template has a rate limit by default.""" @@ -2724,6 +2746,8 @@ async def test_track_template_has_default_rate_limit(hass: HomeAssistant) -> Non await hass.async_block_till_done() assert refresh_runs == [1, 2] + info.async_remove() + async def test_track_template_unavailable_states_has_default_rate_limit( hass: HomeAssistant, @@ -3311,6 +3335,8 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( ] ] + info.async_remove() + async def test_track_same_state_simple_no_trigger(hass: HomeAssistant) -> None: """Test track_same_change with no trigger.""" From a2b6ef3d7b4ad97308eb39f63fa2fa961772613d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 04:01:47 +0100 Subject: [PATCH 0553/1058] Add state attribute translations for fans (#89816) --- homeassistant/components/fan/strings.json | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index 9aae5cf1642d..e9a808e0ae44 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -22,6 +22,30 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "direction": { + "name": "Direction", + "state": { + "forward": "Forward", + "reverse": "Reverse" + } + }, + "oscillating": { + "name": "Oscillating" + }, + "percentage": { + "name": "Speed" + }, + "percentage_step": { + "name": "Speed step" + }, + "preset_modes": { + "name": "Available preset modes" + }, + "preset_mode": { + "name": "Preset mode" + } } } } From a153720599820d0a7101f4c6248548deb71387fb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 04:02:11 +0100 Subject: [PATCH 0554/1058] Add state attribute translations for automations (#89815) --- .../components/automation/strings.json | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index 1663443f0457..4e433119a2ae 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -6,6 +6,29 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "current": { + "name": "Running automations" + }, + "id": { + "name": "ID" + }, + "last_triggered": { + "name": "Last triggered" + }, + "max": { + "name": "Max running automations" + }, + "mode": { + "name": "Run mode", + "state": { + "parallel": "Parallel", + "queued": "Queued", + "restart": "Restart", + "single": "Single" + } + } } } }, From ae127e76876a8c75387e98c3de63d87f21f8e9af Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Mar 2023 04:02:56 +0100 Subject: [PATCH 0555/1058] Change light white service call attribute to accept True (#89803) --- homeassistant/components/light/__init__.py | 8 ++++++- homeassistant/components/light/services.yaml | 23 ++++++++++++++------ tests/components/light/test_init.py | 20 +++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index d8543946df71..2af959d22fe2 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -276,7 +276,7 @@ LIGHT_TURN_ON_SCHEMA = { vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP): vol.All( vol.Coerce(tuple), vol.ExactSequence((cv.small_float, cv.small_float)) ), - vol.Exclusive(ATTR_WHITE, COLOR_GROUP): VALID_BRIGHTNESS, + vol.Exclusive(ATTR_WHITE, COLOR_GROUP): vol.Any(True, VALID_BRIGHTNESS), ATTR_FLASH: VALID_FLASH, ATTR_EFFECT: cv.string, } @@ -557,6 +557,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + # 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 + # integer. + if params.get(ATTR_WHITE) is True: + params[ATTR_WHITE] = light.brightness + # If both white and brightness are specified, override white if ( supported_color_modes diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 65bf77f15c75..d1221dd12107 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -370,19 +370,16 @@ turn_on: unit_of_measurement: "%" white: name: White - description: - Set the light to white mode and change its brightness, where 0 turns - the light off, 1 is the minimum brightness and 255 is the maximum - brightness supported by the light. + description: Set the light to white mode. filter: attribute: supported_color_modes: - light.ColorMode.WHITE advanced: true selector: - number: - min: 0 - max: 255 + constant: + value: true + label: Enabled profile: name: Profile description: Name of a light profile to use. @@ -749,6 +746,18 @@ toggle: min: 0 max: 100 unit_of_measurement: "%" + white: + name: White + description: Set the light to white mode. + filter: + attribute: + supported_color_modes: + - light.ColorMode.WHITE + advanced: true + selector: + constant: + value: true + label: Enabled profile: name: Profile description: Name of a light profile to use. diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 347dfb82bb8c..4b4c027541e4 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -2159,6 +2159,26 @@ async def test_light_service_call_white_mode( _, data = entity0.last_call("turn_off") assert data == {} + entity0.calls = [] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], "white": True}, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"white": 100} + + entity0.calls = [] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": [entity0.entity_id], "brightness_pct": 50, "white": True}, + blocking=True, + ) + _, data = entity0.last_call("turn_on") + assert data == {"white": 128} + async def test_light_state_color_conversion( hass: HomeAssistant, enable_custom_integrations: None From 04a99fdbfc8a8aa31719abe263e3c7ec1e8d77a7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 16 Mar 2023 20:05:01 -0700 Subject: [PATCH 0556/1058] Add local calendar diagnostics platform (#89776) * Add local calendar diagnostics platform * Use redaction from ical * Update diagnostics for new ical version * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Use snapshot tests for local calendar diagnostics * Setup diagnostics directly in tests rather than via dependencies --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/local_calendar/diagnostics.py | 27 ++++++++ .../snapshots/test_diagnostics.ambr | 32 ++++++++++ .../local_calendar/test_diagnostics.py | 62 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 homeassistant/components/local_calendar/diagnostics.py create mode 100644 tests/components/local_calendar/snapshots/test_diagnostics.ambr create mode 100644 tests/components/local_calendar/test_diagnostics.py diff --git a/homeassistant/components/local_calendar/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py new file mode 100644 index 000000000000..51b53ff0073f --- /dev/null +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -0,0 +1,27 @@ +"""Provides diagnostics for local calendar.""" + +import datetime +from typing import Any + +from ical.diagnostics import redact_ics + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + payload: dict[str, Any] = { + "now": dt_util.now().isoformat(), + "timezone": str(dt_util.DEFAULT_TIME_ZONE), + "system_timezone": str(datetime.datetime.utcnow().astimezone().tzinfo), + } + store = hass.data[DOMAIN][config_entry.entry_id] + ics = await store.async_load() + payload["ics"] = "\n".join(redact_ics(ics)) + return payload diff --git a/tests/components/local_calendar/snapshots/test_diagnostics.ambr b/tests/components/local_calendar/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..e61b9da7a903 --- /dev/null +++ b/tests/components/local_calendar/snapshots/test_diagnostics.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_api_date_time_event + dict({ + 'ics': ''' + BEGIN:VCALENDAR + PRODID:-//github.com/allenporter/ical//4.5.0//EN + VERSION:*** + BEGIN:VEVENT + DTSTAMP:20230313T190500 + UID:*** + DTSTART:19970714T110000 + DTEND:19970714T220000 + SUMMARY:*** + CREATED:20230313T190500 + RRULE:FREQ=DAILY + SEQUENCE:*** + END:VEVENT + END:VCALENDAR + ''', + 'now': '2023-03-13T13:05:00-06:00', + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- +# name: test_empty_calendar + dict({ + 'ics': '', + 'now': '2023-03-13T13:05:00-06:00', + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- diff --git a/tests/components/local_calendar/test_diagnostics.py b/tests/components/local_calendar/test_diagnostics.py new file mode 100644 index 000000000000..8b033cf4fdb7 --- /dev/null +++ b/tests/components/local_calendar/test_diagnostics.py @@ -0,0 +1,62 @@ +"""Tests for diagnostics platform of local calendar.""" + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import TEST_ENTITY, ClientFixture + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +async def setup_diag(hass): + """Set up diagnostics platform.""" + assert await async_setup_component(hass, "diagnostics", {}) + + +@freeze_time("2023-03-13 12:05:00-07:00") +async def test_empty_calendar( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics against an empty calendar.""" + data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert data == snapshot + + +@freeze_time("2023-03-13 12:05:00-07:00") +async def test_api_date_time_event( + hass: HomeAssistant, + setup_integration: None, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + ws_client: ClientFixture, + snapshot: SnapshotAssertion, +) -> None: + """Test an event with a start/end date time.""" + + client = await ws_client() + await client.cmd_result( + "create", + { + "entity_id": TEST_ENTITY, + "event": { + "summary": "Bastille Day Party", + "dtstart": "1997-07-14T17:00:00+00:00", + "dtend": "1997-07-15T04:00:00+00:00", + "rrule": "FREQ=DAILY", + }, + }, + ) + + data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert data == snapshot From f6f35657963df04f01c6d4f65357de8842bb5b46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 19:00:02 -1000 Subject: [PATCH 0557/1058] Reduce latency to find stats metadata (#89824) --- homeassistant/components/recorder/core.py | 25 +- .../components/recorder/migration.py | 2 +- .../components/recorder/statistics.py | 212 ++++-------- .../recorder/table_managers/event_data.py | 6 +- .../recorder/table_managers/event_types.py | 6 +- .../table_managers/state_attributes.py | 6 +- .../recorder/table_managers/states_meta.py | 6 +- .../table_managers/statistics_meta.py | 322 ++++++++++++++++++ homeassistant/components/sensor/recorder.py | 106 +++--- .../recorder/table_managers/__init__.py | 1 + .../table_managers/test_statistics_meta.py | 53 +++ tests/components/recorder/test_init.py | 86 ++--- tests/components/recorder/test_statistics.py | 13 +- 13 files changed, 589 insertions(+), 255 deletions(-) create mode 100644 homeassistant/components/recorder/table_managers/statistics_meta.py create mode 100644 tests/components/recorder/table_managers/__init__.py create mode 100644 tests/components/recorder/table_managers/test_statistics_meta.py diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 3468acaed4c6..32a6e9c24dcf 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -86,6 +86,7 @@ from .table_managers.event_types import EventTypeManager from .table_managers.state_attributes import StateAttributesManager from .table_managers.states import StatesManager from .table_managers.states_meta import StatesMetaManager +from .table_managers.statistics_meta import StatisticsMetaManager from .tasks import ( AdjustLRUSizeTask, AdjustStatisticsTask, @@ -172,6 +173,7 @@ class Recorder(threading.Thread): threading.Thread.__init__(self, name="Recorder") self.hass = hass + self.thread_id: int | None = None self.auto_purge = auto_purge self.auto_repack = auto_repack self.keep_days = keep_days @@ -208,6 +210,7 @@ class Recorder(threading.Thread): self.state_attributes_manager = StateAttributesManager( self, exclude_attributes_by_domain ) + self.statistics_meta_manager = StatisticsMetaManager(self) self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None self._completed_first_database_setup: bool | None = None @@ -613,6 +616,7 @@ class Recorder(threading.Thread): def run(self) -> None: """Start processing events to save.""" + self.thread_id = threading.get_ident() setup_result = self._setup_recorder() if not setup_result: @@ -668,7 +672,7 @@ class Recorder(threading.Thread): "Database Migration Failed", "recorder_database_migration", ) - self._activate_and_set_db_ready() + self.hass.add_job(self.async_set_db_ready) self._shutdown() return @@ -687,7 +691,14 @@ class Recorder(threading.Thread): def _activate_and_set_db_ready(self) -> None: """Activate the table managers or schedule migrations and mark the db as ready.""" - with session_scope(session=self.get_session()) as session: + with session_scope(session=self.get_session(), read_only=True) as session: + # Prime the statistics meta manager as soon as possible + # since we want the frontend queries to avoid a thundering + # herd of queries to find the statistics meta data if + # there are a lot of statistics graphs on the frontend. + if self.schema_version >= 23: + self.statistics_meta_manager.load(session) + if ( self.schema_version < 36 or session.execute(has_events_context_ids_to_migrate()).scalar() @@ -758,10 +769,11 @@ class Recorder(threading.Thread): non_state_change_events.append(event_) assert self.event_session is not None - self.event_data_manager.load(non_state_change_events, self.event_session) - self.event_type_manager.load(non_state_change_events, self.event_session) - self.states_meta_manager.load(state_change_events, self.event_session) - self.state_attributes_manager.load(state_change_events, self.event_session) + session = self.event_session + self.event_data_manager.load(non_state_change_events, session) + self.event_type_manager.load(non_state_change_events, session) + 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: """Process a task, guarding against exceptions to ensure the loop does not collapse.""" @@ -1077,6 +1089,7 @@ class Recorder(threading.Thread): self.event_data_manager.reset() self.event_type_manager.reset() self.states_meta_manager.reset() + self.statistics_meta_manager.reset() if not self.event_session: return diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index d594118ea545..08f5f21b896f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -873,7 +873,7 @@ def _apply_update( # noqa: C901 # There may be duplicated statistics_meta entries, delete duplicates # and try again with session_scope(session=session_maker()) as session: - delete_statistics_meta_duplicates(session) + delete_statistics_meta_duplicates(instance, session) _create_index( session_maker, "statistics_meta", "ix_statistics_meta_statistic_id" ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 645b7d4f0427..36874869bf93 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -21,7 +21,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.engine.row import Row from sqlalchemy.exc import OperationalError, SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import literal_column, true +from sqlalchemy.sql.expression import literal_column from sqlalchemy.sql.lambdas import StatementLambdaElement import voluptuous as vol @@ -132,16 +132,6 @@ QUERY_STATISTICS_SUMMARY_SUM = ( .label("rownum"), ) -QUERY_STATISTIC_META = ( - StatisticsMeta.id, - StatisticsMeta.statistic_id, - StatisticsMeta.source, - StatisticsMeta.unit_of_measurement, - StatisticsMeta.has_mean, - StatisticsMeta.has_sum, - StatisticsMeta.name, -) - STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, @@ -373,56 +363,6 @@ def get_start_time() -> datetime: return last_period -def _update_or_add_metadata( - session: Session, - new_metadata: StatisticMetaData, - old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], -) -> int: - """Get metadata_id for a statistic_id. - - If the statistic_id is previously unknown, add it. If it's already known, update - metadata if needed. - - Updating metadata source is not possible. - """ - statistic_id = new_metadata["statistic_id"] - if statistic_id not in old_metadata_dict: - meta = StatisticsMeta.from_meta(new_metadata) - session.add(meta) - session.flush() # Flush to get the metadata id assigned - _LOGGER.debug( - "Added new statistics metadata for %s, new_metadata: %s", - statistic_id, - new_metadata, - ) - return meta.id - - metadata_id, old_metadata = old_metadata_dict[statistic_id] - if ( - old_metadata["has_mean"] != new_metadata["has_mean"] - or old_metadata["has_sum"] != new_metadata["has_sum"] - or old_metadata["name"] != new_metadata["name"] - or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"] - ): - session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( - { - StatisticsMeta.has_mean: new_metadata["has_mean"], - StatisticsMeta.has_sum: new_metadata["has_sum"], - StatisticsMeta.name: new_metadata["name"], - StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], - }, - synchronize_session=False, - ) - _LOGGER.debug( - "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", - statistic_id, - old_metadata, - new_metadata, - ) - - return metadata_id - - def _find_duplicates( session: Session, table: type[StatisticsBase] ) -> tuple[list[int], list[dict]]: @@ -642,13 +582,16 @@ def _delete_statistics_meta_duplicates(session: Session) -> int: return total_deleted_rows -def delete_statistics_meta_duplicates(session: Session) -> None: +def delete_statistics_meta_duplicates(instance: Recorder, session: Session) -> None: """Identify and delete duplicated statistics_meta. This is used when migrating from schema version 28 to schema version 29. """ deleted_statistics_rows = _delete_statistics_meta_duplicates(session) if deleted_statistics_rows: + statistics_meta_manager = instance.statistics_meta_manager + statistics_meta_manager.reset() + statistics_meta_manager.load(session) _LOGGER.info( "Deleted %s duplicated statistics_meta rows", deleted_statistics_rows ) @@ -750,6 +693,7 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - """ start = dt_util.as_utc(start) end = start + timedelta(minutes=5) + statistics_meta_manager = instance.statistics_meta_manager # Return if we already have 5-minute statistics for the requested period with session_scope( @@ -782,7 +726,7 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - # Insert collected statistics in the database for stats in platform_stats: - metadata_id = _update_or_add_metadata( + _, metadata_id = statistics_meta_manager.update_or_add( session, stats["meta"], current_metadata ) _insert_statistics( @@ -877,28 +821,8 @@ def _update_statistics( ) -def _generate_get_metadata_stmt( - statistic_ids: list[str] | None = None, - statistic_type: Literal["mean"] | Literal["sum"] | None = None, - statistic_source: str | None = None, -) -> StatementLambdaElement: - """Generate a statement to fetch metadata.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) - if statistic_ids: - stmt += lambda q: q.where( - # https://github.com/python/mypy/issues/2608 - StatisticsMeta.statistic_id.in_(statistic_ids) # type:ignore[arg-type] - ) - if statistic_source is not None: - stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) - if statistic_type == "mean": - stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) - elif statistic_type == "sum": - stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) - return stmt - - def get_metadata_with_session( + instance: Recorder, session: Session, *, statistic_ids: list[str] | None = None, @@ -908,31 +832,15 @@ def get_metadata_with_session( """Fetch meta data. Returns a dict of (metadata_id, StatisticMetaData) tuples indexed by statistic_id. - If statistic_ids is given, fetch metadata only for the listed statistics_ids. If statistic_type is given, fetch metadata only for statistic_ids supporting it. """ - - # Fetch metatadata from the database - stmt = _generate_get_metadata_stmt(statistic_ids, statistic_type, statistic_source) - result = execute_stmt_lambda_element(session, stmt) - if not result: - return {} - - return { - meta.statistic_id: ( - meta.id, - { - "has_mean": meta.has_mean, - "has_sum": meta.has_sum, - "name": meta.name, - "source": meta.source, - "statistic_id": meta.statistic_id, - "unit_of_measurement": meta.unit_of_measurement, - }, - ) - for meta in result - } + return instance.statistics_meta_manager.get_many( + session, + statistic_ids=statistic_ids, + statistic_type=statistic_type, + statistic_source=statistic_source, + ) def get_metadata( @@ -945,6 +853,7 @@ def get_metadata( """Return metadata for statistic_ids.""" with session_scope(hass=hass, read_only=True) as session: return get_metadata_with_session( + get_instance(hass), session, statistic_ids=statistic_ids, statistic_type=statistic_type, @@ -952,17 +861,10 @@ def get_metadata( ) -def _clear_statistics_with_session(session: Session, statistic_ids: list[str]) -> None: - """Clear statistics for a list of statistic_ids.""" - session.query(StatisticsMeta).filter( - StatisticsMeta.statistic_id.in_(statistic_ids) - ).delete(synchronize_session=False) - - def clear_statistics(instance: Recorder, statistic_ids: list[str]) -> None: """Clear statistics for a list of statistic_ids.""" with session_scope(session=instance.get_session()) as session: - _clear_statistics_with_session(session, statistic_ids) + instance.statistics_meta_manager.delete(session, statistic_ids) def update_statistics_metadata( @@ -972,20 +874,20 @@ def update_statistics_metadata( new_unit_of_measurement: str | None | UndefinedType, ) -> None: """Update statistics metadata for a statistic_id.""" + statistics_meta_manager = instance.statistics_meta_manager if new_unit_of_measurement is not UNDEFINED: with session_scope(session=instance.get_session()) as session: - session.query(StatisticsMeta).filter( - StatisticsMeta.statistic_id == statistic_id - ).update({StatisticsMeta.unit_of_measurement: new_unit_of_measurement}) - if new_statistic_id is not UNDEFINED: + statistics_meta_manager.update_unit_of_measurement( + session, statistic_id, new_unit_of_measurement + ) + if new_statistic_id is not UNDEFINED and new_statistic_id is not None: with session_scope( session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - session.query(StatisticsMeta).filter( - (StatisticsMeta.statistic_id == statistic_id) - & (StatisticsMeta.source == DOMAIN) - ).update({StatisticsMeta.statistic_id: new_statistic_id}) + statistics_meta_manager.update_statistic_id( + session, DOMAIN, statistic_id, new_statistic_id + ) def list_statistic_ids( @@ -1004,7 +906,7 @@ def list_statistic_ids( # Query the database with session_scope(hass=hass, read_only=True) as session: - metadata = get_metadata_with_session( + metadata = get_instance(hass).statistics_meta_manager.get_many( session, statistic_type=statistic_type, statistic_ids=statistic_ids ) @@ -1609,11 +1511,13 @@ def statistic_during_period( with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id if not ( - metadata := get_metadata_with_session(session, statistic_ids=[statistic_id]) + metadata := get_instance(hass).statistics_meta_manager.get( + session, statistic_id + ) ): return result - metadata_id = metadata[statistic_id][0] + metadata_id = metadata[0] oldest_stat = _first_statistic(session, Statistics, metadata_id) oldest_5_min_stat = None @@ -1724,7 +1628,7 @@ def statistic_during_period( else: result["change"] = None - state_unit = unit = metadata[statistic_id][1]["unit_of_measurement"] + state_unit = unit = metadata[1]["unit_of_measurement"] if state := hass.states.get(statistic_id): state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) @@ -1749,7 +1653,9 @@ def _statistics_during_period_with_session( """ metadata = None # Fetch metadata for the given (or all) statistic_ids - metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids + ) if not metadata: return {} @@ -1885,7 +1791,9 @@ def _get_last_statistics( statistic_ids = [statistic_id] with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id - metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids + ) if not metadata: return {} metadata_id = metadata[statistic_id][0] @@ -1973,7 +1881,9 @@ def get_latest_short_term_statistics( with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_ids if not metadata: - metadata = get_metadata_with_session(session, statistic_ids=statistic_ids) + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids + ) if not metadata: return {} metadata_ids = [ @@ -2318,16 +2228,20 @@ def _filter_unique_constraint_integrity_error( def _import_statistics_with_session( + instance: Recorder, session: Session, metadata: StatisticMetaData, statistics: Iterable[StatisticData], table: type[StatisticsBase], ) -> bool: """Import statistics to the database.""" - old_metadata_dict = get_metadata_with_session( + statistics_meta_manager = instance.statistics_meta_manager + old_metadata_dict = statistics_meta_manager.get_many( session, statistic_ids=[metadata["statistic_id"]] ) - metadata_id = _update_or_add_metadata(session, metadata, old_metadata_dict) + _, metadata_id = statistics_meta_manager.update_or_add( + session, metadata, old_metadata_dict + ) for stat in statistics: if stat_id := _statistics_exists(session, table, metadata_id, stat["start"]): _update_statistics(session, table, stat_id, stat) @@ -2350,7 +2264,9 @@ def import_statistics( session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - return _import_statistics_with_session(session, metadata, statistics, table) + return _import_statistics_with_session( + instance, session, metadata, statistics, table + ) @retryable_database_job("adjust_statistics") @@ -2364,7 +2280,9 @@ def adjust_statistics( """Process an add_statistics job.""" with session_scope(session=instance.get_session()) as session: - metadata = get_metadata_with_session(session, statistic_ids=[statistic_id]) + metadata = instance.statistics_meta_manager.get_many( + session, statistic_ids=[statistic_id] + ) if statistic_id not in metadata: return True @@ -2423,10 +2341,9 @@ def change_statistics_unit( old_unit: str, ) -> None: """Change statistics unit for a statistic_id.""" + statistics_meta_manager = instance.statistics_meta_manager with session_scope(session=instance.get_session()) as session: - metadata = get_metadata_with_session(session, statistic_ids=[statistic_id]).get( - statistic_id - ) + metadata = statistics_meta_manager.get(session, statistic_id) # Guard against the statistics being removed or updated before the # change_statistics_unit job executes @@ -2447,9 +2364,10 @@ def change_statistics_unit( ) for table in tables: _change_statistics_unit_for_table(session, table, metadata_id, convert) - session.query(StatisticsMeta).filter( - StatisticsMeta.statistic_id == statistic_id - ).update({StatisticsMeta.unit_of_measurement: new_unit}) + + statistics_meta_manager.update_unit_of_measurement( + session, statistic_id, new_unit + ) @callback @@ -2495,16 +2413,19 @@ def _validate_db_schema_utf8( "statistic_id": statistic_id, "unit_of_measurement": None, } + statistics_meta_manager = instance.statistics_meta_manager # Try inserting some metadata which needs utfmb4 support try: with session_scope(session=session_maker()) as session: - old_metadata_dict = get_metadata_with_session( + old_metadata_dict = statistics_meta_manager.get_many( session, statistic_ids=[statistic_id] ) try: - _update_or_add_metadata(session, metadata, old_metadata_dict) - _clear_statistics_with_session(session, statistic_ids=[statistic_id]) + statistics_meta_manager.update_or_add( + session, metadata, old_metadata_dict + ) + statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) except OperationalError as err: if err.orig and err.orig.args[0] == 1366: _LOGGER.debug( @@ -2524,6 +2445,7 @@ def _validate_db_schema( ) -> set[str]: """Do some basic checks for common schema errors caused by manual migration.""" schema_errors: set[str] = set() + statistics_meta_manager = instance.statistics_meta_manager # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL if instance.dialect_name not in ( @@ -2586,7 +2508,9 @@ def _validate_db_schema( try: with session_scope(session=session_maker()) as session: for table in tables: - _import_statistics_with_session(session, metadata, (statistics,), table) + _import_statistics_with_session( + instance, session, metadata, (statistics,), table + ) stored_statistics = _statistics_during_period_with_session( hass, session, @@ -2625,7 +2549,7 @@ def _validate_db_schema( table.__tablename__, "µs precision", ) - _clear_statistics_with_session(session, statistic_ids=[statistic_id]) + statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) except Exception as exc: # pylint: disable=broad-except _LOGGER.exception("Error when validating DB schema: %s", exc) diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index a99b25fe0b40..4c661e3dc294 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -14,7 +14,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventData from ..queries import get_shared_event_datas -from ..util import chunked +from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: from ..core import Recorder @@ -96,8 +96,8 @@ class EventDataManager(BaseLRUTableManager[EventData]): results: dict[str, int | None] = {} with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for data_id, shared_data in session.execute( - get_shared_event_datas(hashs_chunk) + for data_id, shared_data in execute_stmt_lambda_element( + session, get_shared_event_datas(hashs_chunk) ): results[shared_data] = self._id_map[shared_data] = cast( int, data_id diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 3cb3d9fad97f..5b77e9116c7d 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -12,7 +12,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids -from ..util import chunked +from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: from ..core import Recorder @@ -68,8 +68,8 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): with session.no_autoflush: for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): - for event_type_id, event_type in session.execute( - find_event_type_ids(missing_chunk) + for event_type_id, event_type in execute_stmt_lambda_element( + session, find_event_type_ids(missing_chunk) ): results[event_type] = self._id_map[event_type] = cast( int, event_type_id diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 7489a6f165da..51c626bd3660 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -15,7 +15,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StateAttributes from ..queries import get_shared_attributes -from ..util import chunked +from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: from ..core import Recorder @@ -113,8 +113,8 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): results: dict[str, int | None] = {} with session.no_autoflush: for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): - for attributes_id, shared_attrs in session.execute( - get_shared_attributes(hashs_chunk) + for attributes_id, shared_attrs in execute_stmt_lambda_element( + session, get_shared_attributes(hashs_chunk) ): results[shared_attrs] = self._id_map[shared_attrs] = cast( int, attributes_id diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index b8b763aae330..76b748d46972 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -12,7 +12,7 @@ from . import BaseLRUTableManager from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids -from ..util import chunked +from ..util import chunked, execute_stmt_lambda_element if TYPE_CHECKING: from ..core import Recorder @@ -98,8 +98,8 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): with session.no_autoflush: for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): - for metadata_id, entity_id in session.execute( - find_states_metadata_ids(missing_chunk) + for metadata_id, entity_id in execute_stmt_lambda_element( + session, find_states_metadata_ids(missing_chunk) ): metadata_id = cast(int, metadata_id) results[entity_id] = metadata_id diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py new file mode 100644 index 000000000000..93417b432535 --- /dev/null +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -0,0 +1,322 @@ +"""Support managing StatesMeta.""" +from __future__ import annotations + +import logging +import threading +from typing import TYPE_CHECKING, Literal, cast + +from lru import LRU # pylint: disable=no-name-in-module +from sqlalchemy import lambda_stmt, select +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.expression import true +from sqlalchemy.sql.lambdas import StatementLambdaElement + +from ..db_schema import StatisticsMeta +from ..models import StatisticMetaData +from ..util import execute_stmt_lambda_element + +if TYPE_CHECKING: + from ..core import Recorder + +CACHE_SIZE = 8192 + +_LOGGER = logging.getLogger(__name__) + +QUERY_STATISTIC_META = ( + StatisticsMeta.id, + StatisticsMeta.statistic_id, + StatisticsMeta.source, + StatisticsMeta.unit_of_measurement, + StatisticsMeta.has_mean, + StatisticsMeta.has_sum, + StatisticsMeta.name, +) + + +def _generate_get_metadata_stmt( + statistic_ids: list[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, + statistic_source: str | None = None, +) -> StatementLambdaElement: + """Generate a statement to fetch metadata.""" + stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) + if statistic_ids: + stmt += lambda q: q.where( + # https://github.com/python/mypy/issues/2608 + StatisticsMeta.statistic_id.in_(statistic_ids) # type:ignore[arg-type] + ) + if statistic_source is not None: + stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) + if statistic_type == "mean": + stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) + elif statistic_type == "sum": + stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) + return stmt + + +def _statistics_meta_to_id_statistics_metadata( + meta: StatisticsMeta, +) -> tuple[int, StatisticMetaData]: + """Convert StatisticsMeta tuple of metadata_id and StatisticMetaData.""" + return ( + meta.id, + { + "has_mean": meta.has_mean, # type: ignore[typeddict-item] + "has_sum": meta.has_sum, # type: ignore[typeddict-item] + "name": meta.name, + "source": meta.source, # type: ignore[typeddict-item] + "statistic_id": meta.statistic_id, # type: ignore[typeddict-item] + "unit_of_measurement": meta.unit_of_measurement, + }, + ) + + +class StatisticsMetaManager: + """Manage the StatisticsMeta table.""" + + def __init__(self, recorder: Recorder) -> None: + """Initialize the statistics meta manager.""" + self.recorder = recorder + self._stat_id_to_id_meta: dict[str, tuple[int, StatisticMetaData]] = LRU( + CACHE_SIZE + ) + + def _clear_cache(self, statistic_ids: list[str]) -> None: + """Clear the cache.""" + for statistic_id in statistic_ids: + self._stat_id_to_id_meta.pop(statistic_id, None) + + def _get_from_database( + self, + session: Session, + statistic_ids: list[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, + statistic_source: str | None = None, + ) -> dict[str, tuple[int, StatisticMetaData]]: + """Fetch meta data and process it into results and/or cache.""" + # Only update the cache if we are in the recorder thread and there are no + # new objects that are not yet committed to the database in the session. + update_cache = ( + not session.new + and not session.dirty + and self.recorder.thread_id == threading.get_ident() + ) + results: dict[str, tuple[int, StatisticMetaData]] = {} + with session.no_autoflush: + stat_id_to_id_meta = self._stat_id_to_id_meta + for row in execute_stmt_lambda_element( + session, + _generate_get_metadata_stmt( + statistic_ids, statistic_type, statistic_source + ), + ): + statistics_meta = cast(StatisticsMeta, row) + id_meta = _statistics_meta_to_id_statistics_metadata(statistics_meta) + statistic_id = cast(str, statistics_meta.statistic_id) + results[statistic_id] = id_meta + if update_cache: + stat_id_to_id_meta[statistic_id] = id_meta + return results + + def _assert_in_recorder_thread(self) -> None: + """Assert that we are in the recorder thread.""" + if self.recorder.thread_id != threading.get_ident(): + raise RuntimeError("Detected unsafe call not in recorder thread") + + def _add_metadata( + self, session: Session, statistic_id: str, new_metadata: StatisticMetaData + ) -> int: + """Add metadata to the database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + meta = StatisticsMeta.from_meta(new_metadata) + session.add(meta) + # Flush to assign an ID + session.flush() + _LOGGER.debug( + "Added new statistics metadata for %s, new_metadata: %s", + statistic_id, + new_metadata, + ) + return meta.id + + def _update_metadata( + self, + session: Session, + statistic_id: str, + new_metadata: StatisticMetaData, + old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], + ) -> tuple[bool, int]: + """Update metadata in the database. + + This call is not thread-safe and must be called from the + recorder thread. + """ + metadata_id, old_metadata = old_metadata_dict[statistic_id] + if not ( + old_metadata["has_mean"] != new_metadata["has_mean"] + or old_metadata["has_sum"] != new_metadata["has_sum"] + or old_metadata["name"] != new_metadata["name"] + or old_metadata["unit_of_measurement"] + != new_metadata["unit_of_measurement"] + ): + return False, metadata_id + + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( + { + StatisticsMeta.has_mean: new_metadata["has_mean"], + StatisticsMeta.has_sum: new_metadata["has_sum"], + StatisticsMeta.name: new_metadata["name"], + StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"], + }, + synchronize_session=False, + ) + self._clear_cache([statistic_id]) + _LOGGER.debug( + "Updated statistics metadata for %s, old_metadata: %s, new_metadata: %s", + statistic_id, + old_metadata, + new_metadata, + ) + return True, metadata_id + + def load(self, session: Session) -> None: + """Load the statistic_id to metadata_id mapping into memory. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self.get_many(session) + + def get( + self, session: Session, statistic_id: str + ) -> tuple[int, StatisticMetaData] | None: + """Resolve statistic_id to the metadata_id.""" + return self.get_many(session, [statistic_id]).get(statistic_id) + + def get_many( + self, + session: Session, + statistic_ids: list[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, + statistic_source: str | None = None, + ) -> dict[str, tuple[int, StatisticMetaData]]: + """Fetch meta data. + + Returns a dict of (metadata_id, StatisticMetaData) tuples indexed by statistic_id. + + If statistic_ids is given, fetch metadata only for the listed statistics_ids. + If statistic_type is given, fetch metadata only for statistic_ids supporting it. + """ + if statistic_ids is None: + # Fetch metadata from the database + return self._get_from_database( + session, + statistic_type=statistic_type, + statistic_source=statistic_source, + ) + + if statistic_type is not None or statistic_source is not None: + # This was originally implemented but we never used it + # so the code was ripped out to reduce the maintenance + # burden. + raise ValueError( + "Providing statistic_type and statistic_source is mutually exclusive of statistic_ids" + ) + + results: dict[str, tuple[int, StatisticMetaData]] = {} + missing_statistic_id: list[str] = [] + + for statistic_id in statistic_ids: + if id_meta := self._stat_id_to_id_meta.get(statistic_id): + results[statistic_id] = id_meta + else: + missing_statistic_id.append(statistic_id) + + if not missing_statistic_id: + return results + + # Fetch metadata from the database + return results | self._get_from_database( + session, statistic_ids=missing_statistic_id + ) + + def update_or_add( + self, + session: Session, + new_metadata: StatisticMetaData, + old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], + ) -> tuple[bool, int]: + """Get metadata_id for a statistic_id. + + If the statistic_id is previously unknown, add it. If it's already known, update + metadata if needed. + + Updating metadata source is not possible. + + Returns a tuple of (updated, metadata_id). + + updated is True if the metadata was updated, False if it was not updated. + + This call is not thread-safe and must be called from the + recorder thread. + """ + statistic_id = new_metadata["statistic_id"] + if statistic_id not in old_metadata_dict: + return True, self._add_metadata(session, statistic_id, new_metadata) + return self._update_metadata( + session, statistic_id, new_metadata, old_metadata_dict + ) + + def update_unit_of_measurement( + self, session: Session, statistic_id: str, new_unit: str | None + ) -> None: + """Update the unit of measurement for a statistic_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id == statistic_id + ).update({StatisticsMeta.unit_of_measurement: new_unit}) + self._clear_cache([statistic_id]) + + def update_statistic_id( + self, + session: Session, + source: str, + old_statistic_id: str, + new_statistic_id: str, + ) -> None: + """Update the statistic_id for a statistic_id. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter( + (StatisticsMeta.statistic_id == old_statistic_id) + & (StatisticsMeta.source == source) + ).update({StatisticsMeta.statistic_id: new_statistic_id}) + self._clear_cache([old_statistic_id, new_statistic_id]) + + def delete(self, session: Session, statistic_ids: list[str]) -> None: + """Clear statistics for a list of statistic_ids. + + This call is not thread-safe and must be called from the + recorder thread. + """ + self._assert_in_recorder_thread() + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id.in_(statistic_ids) + ).delete(synchronize_session=False) + self._clear_cache(statistic_ids) + + def reset(self) -> None: + """Reset the cache.""" + self._stat_id_to_id_meta = {} diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index bd4facbea17d..8d5af155fd74 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -145,31 +145,36 @@ def _parse_float(state: str) -> float: return fstate +def _float_or_none(state: str) -> float | None: + """Return a float or None.""" + try: + return _parse_float(state) + except (ValueError, TypeError): + return None + + +def _entity_history_to_float_and_state( + entity_history: Iterable[State], +) -> list[tuple[float, State]]: + """Return a list of (float, state) tuples for the given entity.""" + return [ + (fstate, state) + for state in entity_history + if (fstate := _float_or_none(state.state)) is not None + ] + + def _normalize_states( hass: HomeAssistant, - session: Session, old_metadatas: dict[str, tuple[int, StatisticMetaData]], - entity_history: Iterable[State], + fstates: list[tuple[float, State]], entity_id: str, ) -> tuple[str | None, list[tuple[float, State]]]: """Normalize units.""" - old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None state_unit: str | None = None - - fstates: list[tuple[float, State]] = [] - for state in entity_history: - try: - fstate = _parse_float(state.state) - except (ValueError, TypeError): # TypeError to guard for NULL state in DB - continue - fstates.append((fstate, state)) - - if not fstates: - return None, fstates - - state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) - statistics_unit: str | None + state_unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + old_metadata = old_metadatas[entity_id][1] if entity_id in old_metadatas else None if not old_metadata: # We've not seen this sensor before, the first valid state determines the unit # used for statistics @@ -379,7 +384,15 @@ def compile_statistics( Note: This will query the database and must not be run in the event loop """ - with recorder_util.session_scope(hass=hass) as session: + # There is already an active session when this code is called since + # it is called from the recorder statistics. We need to make sure + # this session never gets committed since it would be out of sync + # with the recorder statistics session so we mark it as read only. + # + # If we ever need to write to the database from this function we + # will need to refactor the recorder statistics to use a single + # session. + with recorder_util.session_scope(hass=hass, read_only=True) as session: compiled = _compile_statistics(hass, session, start, end) return compiled @@ -395,10 +408,6 @@ def _compile_statistics( # noqa: C901 sensor_states = _get_sensor_states(hass) wanted_statistics = _wanted_statistics(sensor_states) - old_metadatas = statistics.get_metadata_with_session( - session, statistic_ids=[i.entity_id for i in sensor_states] - ) - # Get history between start and end entities_full_history = [ i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id] @@ -427,34 +436,41 @@ def _compile_statistics( # noqa: C901 entity_ids=entities_significant_history, ) history_list = {**history_list, **_history_list} - # If there are no recent state changes, the sensor's state may already be pruned - # from the recorder. Get the state from the state machine instead. - for _state in sensor_states: - if _state.entity_id not in history_list: - history_list[_state.entity_id] = [_state] - to_process = [] - to_query = [] + entities_with_float_states: dict[str, list[tuple[float, State]]] = {} for _state in sensor_states: entity_id = _state.entity_id - if entity_id not in history_list: + # If there are no recent state changes, the sensor's state may already be pruned + # from the recorder. Get the state from the state machine instead. + if not (entity_history := history_list.get(entity_id, [_state])): continue + if not (float_states := _entity_history_to_float_and_state(entity_history)): + continue + entities_with_float_states[entity_id] = float_states - entity_history = history_list[entity_id] - statistics_unit, fstates = _normalize_states( + # Only lookup metadata for entities that have valid float states + # since it will result in cache misses for statistic_ids + # that are not in the metadata table and we are not working + # with them anyway. + old_metadatas = statistics.get_metadata_with_session( + get_instance(hass), session, statistic_ids=list(entities_with_float_states) + ) + to_process: list[tuple[str, str | None, str, list[tuple[float, State]]]] = [] + to_query: list[str] = [] + for _state in sensor_states: + entity_id = _state.entity_id + if not (maybe_float_states := entities_with_float_states.get(entity_id)): + continue + statistics_unit, valid_float_states = _normalize_states( hass, - session, old_metadatas, - entity_history, + maybe_float_states, entity_id, ) - - if not fstates: + if not valid_float_states: continue - - state_class = _state.attributes[ATTR_STATE_CLASS] - - to_process.append((entity_id, statistics_unit, state_class, fstates)) + state_class: str = _state.attributes[ATTR_STATE_CLASS] + to_process.append((entity_id, statistics_unit, state_class, valid_float_states)) if "sum" in wanted_statistics[entity_id]: to_query.append(entity_id) @@ -465,7 +481,7 @@ def _compile_statistics( # noqa: C901 entity_id, statistics_unit, state_class, - fstates, + valid_float_states, ) in to_process: # Check metadata if old_metadata := old_metadatas.get(entity_id): @@ -507,20 +523,20 @@ def _compile_statistics( # noqa: C901 if "max" in wanted_statistics[entity_id]: stat["max"] = max( *itertools.islice( - zip(*fstates), # type: ignore[typeddict-item] + zip(*valid_float_states), # type: ignore[typeddict-item] 1, ) ) if "min" in wanted_statistics[entity_id]: stat["min"] = min( *itertools.islice( - zip(*fstates), # type: ignore[typeddict-item] + zip(*valid_float_states), # type: ignore[typeddict-item] 1, ) ) if "mean" in wanted_statistics[entity_id]: - stat["mean"] = _time_weighted_average(fstates, start, end) + stat["mean"] = _time_weighted_average(valid_float_states, start, end) if "sum" in wanted_statistics[entity_id]: last_reset = old_last_reset = None @@ -535,7 +551,7 @@ def _compile_statistics( # noqa: C901 new_state = old_state = last_stat["state"] _sum = last_stat["sum"] or 0.0 - for fstate, state in fstates: + for fstate, state in valid_float_states: reset = False if ( state_class != SensorStateClass.TOTAL_INCREASING diff --git a/tests/components/recorder/table_managers/__init__.py b/tests/components/recorder/table_managers/__init__.py new file mode 100644 index 000000000000..52685ec18fa8 --- /dev/null +++ b/tests/components/recorder/table_managers/__init__.py @@ -0,0 +1 @@ +"""Tests for the recorder table managers.""" diff --git a/tests/components/recorder/table_managers/test_statistics_meta.py b/tests/components/recorder/table_managers/test_statistics_meta.py new file mode 100644 index 000000000000..8ec3f9367d64 --- /dev/null +++ b/tests/components/recorder/table_managers/test_statistics_meta.py @@ -0,0 +1,53 @@ +"""The tests for the Recorder component.""" +from __future__ import annotations + +import pytest + +from homeassistant.components import recorder +from homeassistant.components.recorder.util import session_scope +from homeassistant.core import HomeAssistant + +from tests.typing import RecorderInstanceGenerator + + +async def test_passing_mutually_exclusive_options_to_get_many( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test passing mutually exclusive options to get_many.""" + instance = await async_setup_recorder_instance( + hass, {recorder.CONF_COMMIT_INTERVAL: 0} + ) + with session_scope(session=instance.get_session()) as session: + with pytest.raises(ValueError): + instance.statistics_meta_manager.get_many( + session, + statistic_ids=["light.kitchen"], + statistic_type="mean", + ) + with pytest.raises(ValueError): + instance.statistics_meta_manager.get_many( + session, statistic_ids=["light.kitchen"], statistic_source="sensor" + ) + assert ( + instance.statistics_meta_manager.get_many( + session, + statistic_ids=["light.kitchen"], + ) + == {} + ) + + +async def test_unsafe_calls_to_statistics_meta_manager( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we raise when trying to call non-threadsafe functions on statistics_meta_manager.""" + instance = await async_setup_recorder_instance( + hass, {recorder.CONF_COMMIT_INTERVAL: 0} + ) + with session_scope(session=instance.get_session()) as session, pytest.raises( + RuntimeError, match="Detected unsafe call not in recorder thread" + ): + instance.statistics_meta_manager.delete( + session, + statistic_ids=["light.kitchen"], + ) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index e3b9145dc8bd..8c1d8ef00aa2 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -564,32 +564,6 @@ def _add_entities(hass, entity_ids): return states -def _add_events(hass, events): - with session_scope(hass=hass) as session: - session.query(Events).delete(synchronize_session=False) - for event_type in events: - hass.bus.fire(event_type) - wait_recording_done(hass) - - with session_scope(hass=hass) as session: - events = [] - for event, event_data, event_types in ( - session.query(Events, EventData, EventTypes) - .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) - .outerjoin(EventData, Events.data_id == EventData.data_id) - ): - event = cast(Events, event) - event_data = cast(EventData, event_data) - event_types = cast(EventTypes, event_types) - - native_event = event.to_native() - if event_data: - native_event.data = event_data.to_native() - native_event.event_type = event_types.event_type - events.append(native_event) - return events - - def _state_with_context(hass, entity_id): # We don't restore context unless we need it by joining the # events table on the event_id for state_changed events @@ -646,25 +620,53 @@ def test_saving_state_incl_entities( assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() -def test_saving_event_exclude_event_type( - hass_recorder: Callable[..., HomeAssistant] +async def test_saving_event_exclude_event_type( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, ) -> None: """Test saving and restoring an event.""" - hass = hass_recorder( - { - "exclude": { - "event_types": [ - "service_registered", - "homeassistant_start", - "component_loaded", - "core_config_updated", - "homeassistant_started", - "test", - ] - } + config = { + "exclude": { + "event_types": [ + "service_registered", + "homeassistant_start", + "component_loaded", + "core_config_updated", + "homeassistant_started", + "test", + ] } - ) - events = _add_events(hass, ["test", "test2"]) + } + instance = await async_setup_recorder_instance(hass, config) + events = ["test", "test2"] + for event_type in events: + hass.bus.async_fire(event_type) + + await async_wait_recording_done(hass) + + def _get_events(hass: HomeAssistant, event_types: list[str]) -> list[Event]: + with session_scope(hass=hass) as session: + events = [] + for event, event_data, event_types in ( + session.query(Events, EventData, EventTypes) + .outerjoin( + EventTypes, (Events.event_type_id == EventTypes.event_type_id) + ) + .outerjoin(EventData, Events.data_id == EventData.data_id) + .where(EventTypes.event_type.in_(event_types)) + ): + event = cast(Events, event) + event_data = cast(EventData, event_data) + event_types = cast(EventTypes, event_types) + + native_event = event.to_native() + if event_data: + native_event.data = event_data.to_native() + native_event.event_type = event_types.event_type + events.append(native_event) + return events + + events = await instance.async_add_executor_job(_get_events, hass, ["test", "test2"]) assert len(events) == 1 assert events[0].event_type == "test2" diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 7c064a03edfd..46d2c92e4631 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -22,12 +22,10 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.components.recorder.statistics import ( STATISTIC_UNIT_TO_UNIT_CONVERTER, - _generate_get_metadata_stmt, _generate_max_mean_min_statistic_in_sub_period_stmt, _generate_statistics_at_time_stmt, _generate_statistics_during_period_stmt, _statistics_during_period_with_session, - _update_or_add_metadata, async_add_external_statistics, async_import_statistics, delete_statistics_duplicates, @@ -38,6 +36,10 @@ from homeassistant.components.recorder.statistics import ( get_metadata, list_statistic_ids, ) +from homeassistant.components.recorder.table_managers.statistics_meta import ( + StatisticsMetaManager, + _generate_get_metadata_stmt, +) from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import UNIT_CONVERTERS from homeassistant.const import UnitOfTemperature @@ -1520,7 +1522,8 @@ def test_delete_metadata_duplicates_no_duplicates( hass = hass_recorder() wait_recording_done(hass) with session_scope(hass=hass) as session: - delete_statistics_meta_duplicates(session) + instance = recorder.get_instance(hass) + delete_statistics_meta_duplicates(instance, session) assert "duplicated statistics_meta rows" not in caplog.text @@ -1562,9 +1565,9 @@ async def test_validate_db_schema_fix_utf8_issue( with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" ), patch( - "homeassistant.components.recorder.statistics._update_or_add_metadata", + "homeassistant.components.recorder.table_managers.statistics_meta.StatisticsMetaManager.update_or_add", + wraps=StatisticsMetaManager.update_or_add, side_effect=[utf8_error, DEFAULT, DEFAULT], - wraps=_update_or_add_metadata, ): await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) From 5a6234d60e430a70cd05a2b9e0b271b9f2121389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roy?= Date: Thu, 16 Mar 2023 22:13:23 -0700 Subject: [PATCH 0558/1058] Bump aiobafi6 to 0.8.0 (#89840) --- homeassistant/components/baf/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index 8229a912fbb3..b5b5b76967e6 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", "iot_class": "local_push", - "requirements": ["aiobafi6==0.7.3"], + "requirements": ["aiobafi6==0.8.0"], "zeroconf": [ { "type": "_api._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 5d1a0b634d39..fc2fd2c26a86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.7.3 +aiobafi6==0.8.0 # homeassistant.components.aws aiobotocore==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffca3813dd5d..042f2dd4923d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -121,7 +121,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.7.3 +aiobafi6==0.8.0 # homeassistant.components.aws aiobotocore==2.1.0 From d671d7fc1fce4f6c41222dd66fb04ce59a543a41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 19:13:42 -1000 Subject: [PATCH 0559/1058] Add native_step to baf (#89780) --- homeassistant/components/baf/number.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index ca4b591cf9ef..91fe110d388a 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -39,6 +39,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_min_speed", name="Auto Comfort Minimum Speed", + native_step=1, native_min_value=0, native_max_value=SPEED_RANGE[1] - 1, entity_category=EntityCategory.CONFIG, @@ -48,6 +49,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_max_speed", name="Auto Comfort Maximum Speed", + native_step=1, native_min_value=1, native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, @@ -57,6 +59,7 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_heat_assist_speed", name="Auto Comfort Heat Assist Speed", + native_step=1, native_min_value=SPEED_RANGE[0], native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, @@ -69,6 +72,7 @@ FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="return_to_auto_timeout", name="Return to Auto Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -79,6 +83,7 @@ FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="motion_sense_timeout", name="Motion Sense Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -92,6 +97,7 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_return_to_auto_timeout", name="Light Return to Auto Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, @@ -102,6 +108,7 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_auto_motion_timeout", name="Light Motion Sense Timeout", + native_step=1, native_min_value=ONE_MIN_SECS, native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, From dbb2706c76de3c04958c3d0d97cd052106805767 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 21:07:14 -1000 Subject: [PATCH 0560/1058] Reduce number of tasks created by compiling missing statistics (#89835) --- homeassistant/components/recorder/core.py | 37 +---- .../components/recorder/statistics.py | 143 ++++++++++++------ homeassistant/components/recorder/tasks.py | 12 ++ 3 files changed, 118 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 32a6e9c24dcf..db44baee06c2 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -14,7 +14,7 @@ import time from typing import Any, TypeVar import async_timeout -from sqlalchemy import create_engine, event as sqlalchemy_event, exc, func, select +from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select from sqlalchemy.engine import Engine from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import scoped_session, sessionmaker @@ -62,17 +62,10 @@ from .db_schema import ( States, StatesMeta, Statistics, - StatisticsRuns, StatisticsShortTerm, ) from .executor import DBInterruptibleThreadPoolExecutor -from .models import ( - DatabaseEngine, - StatisticData, - StatisticMetaData, - UnsupportedDialect, - process_timestamp, -) +from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect from .pool import POOL_SIZE, MutexPool, RecorderPool from .queries import ( has_entity_ids_to_migrate, @@ -93,6 +86,7 @@ from .tasks import ( ChangeStatisticsUnitTask, ClearStatisticsTask, CommitTask, + CompileMissingStatisticsTask, DatabaseLockTask, EntityIDMigrationTask, EventsContextIDMigrationTask, @@ -680,9 +674,7 @@ class Recorder(threading.Thread): self._activate_and_set_db_ready() # Catch up with missed statistics - with session_scope(session=self.get_session()) as session: - self._schedule_compile_missing_statistics(session) - + self._schedule_compile_missing_statistics() _LOGGER.debug("Recorder processing the queue") self._adjust_lru_size() self.hass.add_job(self._async_set_recorder_ready_migration_done) @@ -1295,26 +1287,9 @@ class Recorder(threading.Thread): self._open_event_session() - def _schedule_compile_missing_statistics(self, session: Session) -> None: + def _schedule_compile_missing_statistics(self) -> None: """Add tasks for missing statistics runs.""" - now = dt_util.utcnow() - last_period_minutes = now.minute - now.minute % 5 - last_period = now.replace(minute=last_period_minutes, second=0, microsecond=0) - start = now - timedelta(days=self.keep_days) - start = start.replace(minute=0, second=0, microsecond=0) - - # Find the newest statistics run, if any - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): - start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) - - # Add tasks - while start < last_period: - end = start + timedelta(minutes=5) - _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) - self.queue_task(StatisticsTask(start, end >= last_period)) - start = end + self.queue_task(CompileMissingStatisticsTask()) def _end_session(self) -> None: """End the recorder session.""" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 36874869bf93..fcd934270d16 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -72,6 +72,7 @@ from .models import ( StatisticMetaData, StatisticResult, datetime_to_timestamp_or_none, + process_timestamp, ) from .util import ( database_job_retry_wrapper, @@ -685,69 +686,125 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None: ) -@retryable_database_job("statistics") +@retryable_database_job("compile missing statistics") +def compile_missing_statistics(instance: Recorder) -> bool: + """Compile missing statistics.""" + now = dt_util.utcnow() + period_size = 5 + last_period_minutes = now.minute - now.minute % period_size + last_period = now.replace(minute=last_period_minutes, second=0, microsecond=0) + start = now - timedelta(days=instance.keep_days) + start = start.replace(minute=0, second=0, microsecond=0) + # Commit every 12 hours of data + commit_interval = 60 / period_size * 12 + + with session_scope( + session=instance.get_session(), + exception_filter=_filter_unique_constraint_integrity_error(instance), + ) as session: + # Find the newest statistics run, if any + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + if last_run := session.query(func.max(StatisticsRuns.start)).scalar(): + start = max(start, process_timestamp(last_run) + timedelta(minutes=5)) + + periods_without_commit = 0 + while start < last_period: + periods_without_commit += 1 + end = start + timedelta(minutes=period_size) + _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) + metadata_modified = _compile_statistics( + instance, session, start, end >= last_period + ) + if periods_without_commit == commit_interval or metadata_modified: + session.commit() + session.expunge_all() + periods_without_commit = 0 + start = end + + return True + + +@retryable_database_job("compile statistics") def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) -> bool: """Compile 5-minute statistics for all integrations with a recorder platform. The actual calculation is delegated to the platforms. """ - start = dt_util.as_utc(start) - end = start + timedelta(minutes=5) - statistics_meta_manager = instance.statistics_meta_manager - # Return if we already have 5-minute statistics for the requested period with session_scope( session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - if session.query(StatisticsRuns).filter_by(start=start).first(): - _LOGGER.debug("Statistics already compiled for %s-%s", start, end) - return True + _compile_statistics(instance, session, start, fire_events) + return True - _LOGGER.debug("Compiling statistics for %s-%s", start, end) - platform_stats: list[StatisticResult] = [] - current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} - # Collect statistics from all platforms implementing support - for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): - if not hasattr(platform, "compile_statistics"): - continue - compiled: PlatformCompiledStatistics = platform.compile_statistics( - instance.hass, start, end - ) - _LOGGER.debug( - "Statistics for %s during %s-%s: %s", - domain, - start, - end, - compiled.platform_stats, - ) - platform_stats.extend(compiled.platform_stats) - current_metadata.update(compiled.current_metadata) - # Insert collected statistics in the database - for stats in platform_stats: - _, metadata_id = statistics_meta_manager.update_or_add( - session, stats["meta"], current_metadata - ) - _insert_statistics( - session, - StatisticsShortTerm, - metadata_id, - stats["stat"], - ) +def _compile_statistics( + instance: Recorder, session: Session, start: datetime, fire_events: bool +) -> bool: + """Compile 5-minute statistics for all integrations with a recorder platform. - if start.minute == 55: - # A full hour is ready, summarize it - _compile_hourly_statistics(session, start) + This is a helper function for compile_statistics and compile_missing_statistics + that does not retry on database errors since both callers already retry. - session.add(StatisticsRuns(start=start)) + returns True if metadata was modified, False otherwise + """ + assert start.tzinfo == dt_util.UTC, "start must be in UTC" + end = start + timedelta(minutes=5) + statistics_meta_manager = instance.statistics_meta_manager + metadata_modified = False + + # Return if we already have 5-minute statistics for the requested period + if session.query(StatisticsRuns).filter_by(start=start).first(): + _LOGGER.debug("Statistics already compiled for %s-%s", start, end) + return metadata_modified + + _LOGGER.debug("Compiling statistics for %s-%s", start, end) + platform_stats: list[StatisticResult] = [] + current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} + # Collect statistics from all platforms implementing support + for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): + if not hasattr(platform, "compile_statistics"): + continue + compiled: PlatformCompiledStatistics = platform.compile_statistics( + instance.hass, start, end + ) + _LOGGER.debug( + "Statistics for %s during %s-%s: %s", + domain, + start, + end, + compiled.platform_stats, + ) + platform_stats.extend(compiled.platform_stats) + current_metadata.update(compiled.current_metadata) + + # Insert collected statistics in the database + for stats in platform_stats: + updated, metadata_id = statistics_meta_manager.update_or_add( + session, stats["meta"], current_metadata + ) + metadata_modified |= updated + _insert_statistics( + session, + StatisticsShortTerm, + metadata_id, + stats["stat"], + ) + + if start.minute == 55: + # A full hour is ready, summarize it + _compile_hourly_statistics(session, start) + + session.add(StatisticsRuns(start=start)) if fire_events: instance.hass.bus.fire(EVENT_RECORDER_5MIN_STATISTICS_GENERATED) if start.minute == 55: instance.hass.bus.fire(EVENT_RECORDER_HOURLY_STATISTICS_GENERATED) - return True + return metadata_modified def _adjust_sum_statistics( diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index f2ba42bdea72..5762a9ab69cd 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -151,6 +151,18 @@ class StatisticsTask(RecorderTask): instance.queue_task(StatisticsTask(self.start, self.fire_events)) +@dataclass +class CompileMissingStatisticsTask(RecorderTask): + """An object to insert into the recorder queue to run a compile missing statistics.""" + + def run(self, instance: Recorder) -> None: + """Run statistics task to compile missing statistics.""" + if statistics.compile_missing_statistics(instance): + return + # Schedule a new statistics task if this one didn't finish + instance.queue_task(CompileMissingStatisticsTask()) + + @dataclass class ImportStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an import statistics task.""" From ab4a726e6cfcf171711f416c70d77920816c364b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 10:22:02 +0100 Subject: [PATCH 0561/1058] Add tmpdir to known fixtures in pylint (#89844) --- pylint/plugins/hass_enforce_type_hints.py | 1 + tests/components/filesize/test_init.py | 8 +++--- tests/components/filesize/test_sensor.py | 6 +++-- tests/components/http/test_init.py | 25 ++++++++++++------- .../lutron_caseta/test_config_flow.py | 15 +++++++---- tests/components/profiler/test_init.py | 7 +++--- tests/components/recorder/test_init.py | 9 ++++--- tests/components/recorder/test_statistics.py | 7 ++++-- .../recorder/test_statistics_v23_migration.py | 15 ++++++++--- tests/components/recorder/test_util.py | 3 ++- .../components/recorder/test_v32_migration.py | 5 +++- tests/helpers/test_storage.py | 3 ++- tests/helpers/test_storage_remove.py | 4 ++- tests/test_runner.py | 11 +++++--- tests/util/test_file.py | 11 ++++---- 15 files changed, 86 insertions(+), 44 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 4092beb3da1e..c63fde19c8ed 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -142,6 +142,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "requests_mock": "requests_mock.Mocker", "snapshot": "SnapshotAssertion", "tmp_path": "Path", + "tmpdir": "py.path.local", } _TEST_FUNCTION_MATCH = TypeHintMatch( function_name="test_*", diff --git a/tests/components/filesize/test_init.py b/tests/components/filesize/test_init.py index effab8f75d86..7c0526b8194d 100644 --- a/tests/components/filesize/test_init.py +++ b/tests/components/filesize/test_init.py @@ -1,4 +1,6 @@ """Tests for the Filesize integration.""" +import py + from homeassistant.components.filesize.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_FILE_PATH @@ -10,7 +12,7 @@ from tests.common import MockConfigEntry async def test_load_unload_config_entry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: str + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: py.path.local ) -> None: """Test the Filesize configuration entry loading/unloading.""" testfile = f"{tmpdir}/file.txt" @@ -33,7 +35,7 @@ async def test_load_unload_config_entry( async def test_cannot_access_file( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: str + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: py.path.local ) -> None: """Test that an file not exist is caught.""" mock_config_entry.add_to_hass(hass) @@ -50,7 +52,7 @@ async def test_cannot_access_file( async def test_not_valid_path_to_file( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: str + hass: HomeAssistant, mock_config_entry: MockConfigEntry, tmpdir: py.path.local ) -> None: """Test that an invalid path is caught.""" testfile = f"{tmpdir}/file.txt" diff --git a/tests/components/filesize/test_sensor.py b/tests/components/filesize/test_sensor.py index 5b072769f562..bef0eceb6537 100644 --- a/tests/components/filesize/test_sensor.py +++ b/tests/components/filesize/test_sensor.py @@ -1,6 +1,8 @@ """The tests for the filesize sensor.""" import os +import py + from homeassistant.const import CONF_FILE_PATH, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity @@ -24,7 +26,7 @@ async def test_invalid_path( async def test_valid_path( - hass: HomeAssistant, tmpdir: str, mock_config_entry: MockConfigEntry + hass: HomeAssistant, tmpdir: py.path.local, mock_config_entry: MockConfigEntry ) -> None: """Test for a valid path.""" testfile = f"{tmpdir}/file.txt" @@ -46,7 +48,7 @@ async def test_valid_path( async def test_state_unavailable( - hass: HomeAssistant, tmpdir: str, mock_config_entry: MockConfigEntry + hass: HomeAssistant, tmpdir: py.path.local, mock_config_entry: MockConfigEntry ) -> None: """Verify we handle state unavailable.""" testfile = f"{tmpdir}/file.txt" diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index f5a69bd864b7..578fcc60c7c6 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -6,6 +6,7 @@ import logging import pathlib from unittest.mock import Mock, patch +import py import pytest from homeassistant.auth.providers.legacy_api_password import ( @@ -150,7 +151,9 @@ async def test_proxy_config_only_trust_proxies(hass: HomeAssistant) -> None: ) -async def test_ssl_profile_defaults_modern(hass: HomeAssistant, tmpdir) -> None: +async def test_ssl_profile_defaults_modern( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test default ssl profile.""" cert_path, key_path, _ = await hass.async_add_executor_job( @@ -175,7 +178,9 @@ async def test_ssl_profile_defaults_modern(hass: HomeAssistant, tmpdir) -> None: assert len(mock_context.mock_calls) == 1 -async def test_ssl_profile_change_intermediate(hass: HomeAssistant, tmpdir) -> None: +async def test_ssl_profile_change_intermediate( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test setting ssl profile to intermediate.""" cert_path, key_path, _ = await hass.async_add_executor_job( @@ -206,7 +211,9 @@ async def test_ssl_profile_change_intermediate(hass: HomeAssistant, tmpdir) -> N assert len(mock_context.mock_calls) == 1 -async def test_ssl_profile_change_modern(hass: HomeAssistant, tmpdir) -> None: +async def test_ssl_profile_change_modern( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test setting ssl profile to modern.""" cert_path, key_path, _ = await hass.async_add_executor_job( @@ -237,7 +244,7 @@ async def test_ssl_profile_change_modern(hass: HomeAssistant, tmpdir) -> None: assert len(mock_context.mock_calls) == 1 -async def test_peer_cert(hass: HomeAssistant, tmpdir) -> None: +async def test_peer_cert(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test required peer cert.""" cert_path, key_path, peer_cert_path = await hass.async_add_executor_job( _setup_empty_ssl_pem_files, tmpdir @@ -272,7 +279,7 @@ async def test_peer_cert(hass: HomeAssistant, tmpdir) -> None: async def test_emergency_ssl_certificate_when_invalid( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test http can startup with an emergency self signed cert when the current one is broken.""" @@ -303,7 +310,7 @@ async def test_emergency_ssl_certificate_when_invalid( async def test_emergency_ssl_certificate_not_used_when_not_safe_mode( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test an emergency cert is only used in safe mode.""" @@ -320,7 +327,7 @@ async def test_emergency_ssl_certificate_not_used_when_not_safe_mode( async def test_emergency_ssl_certificate_when_invalid_get_url_fails( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken. @@ -357,7 +364,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( async def test_invalid_ssl_and_cannot_create_emergency_cert( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken.""" @@ -388,7 +395,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken. diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index cc71eb5910f6..9518528714b7 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -3,6 +3,7 @@ import asyncio import ssl from unittest.mock import AsyncMock, patch +import py from pylutron_caseta.pairing import PAIR_CA, PAIR_CERT, PAIR_KEY from pylutron_caseta.smartbridge import Smartbridge import pytest @@ -192,7 +193,7 @@ async def test_already_configured_with_ignored(hass: HomeAssistant) -> None: assert result["type"] == "form" -async def test_form_user(hass: HomeAssistant, tmpdir) -> None: +async def test_form_user(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we get the form and can pair.""" hass.config.config_dir = await hass.async_add_executor_job( @@ -243,7 +244,9 @@ async def test_form_user(hass: HomeAssistant, tmpdir) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_user_pairing_fails(hass: HomeAssistant, tmpdir) -> None: +async def test_form_user_pairing_fails( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test we get the form and we handle pairing failure.""" hass.config.config_dir = await hass.async_add_executor_job( @@ -289,7 +292,7 @@ async def test_form_user_pairing_fails(hass: HomeAssistant, tmpdir) -> None: async def test_form_user_reuses_existing_assets_when_pairing_again( - hass: HomeAssistant, tmpdir + hass: HomeAssistant, tmpdir: py.path.local ) -> None: """Test the tls assets saved on disk are reused when pairing again.""" @@ -390,7 +393,9 @@ async def test_form_user_reuses_existing_assets_when_pairing_again( } -async def test_zeroconf_host_already_configured(hass: HomeAssistant, tmpdir) -> None: +async def test_zeroconf_host_already_configured( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test starting a flow from discovery when the host is already configured.""" hass.config.config_dir = await hass.async_add_executor_job( @@ -474,7 +479,7 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "source", (config_entries.SOURCE_ZEROCONF, config_entries.SOURCE_HOMEKIT) ) -async def test_zeroconf(hass: HomeAssistant, source, tmpdir) -> None: +async def test_zeroconf(hass: HomeAssistant, source, tmpdir: py.path.local) -> None: """Test starting a flow from discovery.""" hass.config.config_dir = await hass.async_add_executor_job( diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 636067341e11..0f46f306fefb 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -4,6 +4,7 @@ import os import sys from unittest.mock import patch +import py import pytest from homeassistant.components.profiler import ( @@ -25,7 +26,7 @@ import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -async def test_basic_usage(hass: HomeAssistant, tmpdir) -> None: +async def test_basic_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we can setup and the service is registered.""" test_dir = tmpdir.mkdir("profiles") @@ -58,7 +59,7 @@ async def test_basic_usage(hass: HomeAssistant, tmpdir) -> None: @pytest.mark.skipif( sys.version_info >= (3, 11), reason="not yet available on python 3.11" ) -async def test_memory_usage(hass: HomeAssistant, tmpdir) -> None: +async def test_memory_usage(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we can setup and the service is registered.""" test_dir = tmpdir.mkdir("profiles") @@ -89,7 +90,7 @@ async def test_memory_usage(hass: HomeAssistant, tmpdir) -> None: @pytest.mark.skipif(sys.version_info < (3, 11), reason="still works on python 3.10") -async def test_memory_usage_py311(hass: HomeAssistant, tmpdir) -> None: +async def test_memory_usage_py311(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test raise an error on python3.11.""" entry = MockConfigEntry(domain=DOMAIN) entry.add_to_hass(hass) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 8c1d8ef00aa2..48429d7a11d8 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -11,6 +11,7 @@ from typing import cast from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory +import py import pytest from sqlalchemy.exc import DatabaseError, OperationalError, SQLAlchemyError @@ -1222,7 +1223,9 @@ def test_statistics_runs_initiated(hass_recorder: Callable[..., HomeAssistant]) @pytest.mark.freeze_time("2022-09-13 09:00:00+02:00") -def test_compile_missing_statistics(tmpdir, freezer: FrozenDateTimeFactory) -> None: +def test_compile_missing_statistics( + tmpdir: py.path.local, freezer: FrozenDateTimeFactory +) -> None: """Test missing statistics are compiled on startup.""" now = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") @@ -1482,7 +1485,7 @@ def test_service_disable_states_not_recording( ) -def test_service_disable_run_information_recorded(tmpdir) -> None: +def test_service_disable_run_information_recorded(tmpdir: py.path.local) -> None: """Test that runs are still recorded when recorder is disabled.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -1531,7 +1534,7 @@ class CannotSerializeMe: async def test_database_corruption_while_running( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test we can recover from sqlite3 db corruption.""" diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 46d2c92e4631..522a3eff2e6d 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -7,6 +7,7 @@ import importlib import sys from unittest.mock import ANY, DEFAULT, MagicMock, patch, sentinel +import py import pytest from sqlalchemy import create_engine, select from sqlalchemy.exc import OperationalError @@ -1327,7 +1328,9 @@ def _create_engine_28(*args, **kwargs): return engine -def test_delete_metadata_duplicates(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +def test_delete_metadata_duplicates( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -1419,7 +1422,7 @@ def test_delete_metadata_duplicates(caplog: pytest.LogCaptureFixture, tmpdir) -> def test_delete_metadata_duplicates_many( - caplog: pytest.LogCaptureFixture, tmpdir + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local ) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index ec36d7eb8305..48db847869de 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -9,6 +9,7 @@ import json import sys from unittest.mock import patch +import py import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session @@ -52,7 +53,9 @@ def _create_engine_test(*args, **kwargs): return engine -def test_delete_duplicates(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +def test_delete_duplicates( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -222,7 +225,9 @@ def test_delete_duplicates(caplog: pytest.LogCaptureFixture, tmpdir) -> None: assert "Found duplicated" not in caplog.text -def test_delete_duplicates_many(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +def test_delete_duplicates_many( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" @@ -400,7 +405,7 @@ def test_delete_duplicates_many(caplog: pytest.LogCaptureFixture, tmpdir) -> Non @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") def test_delete_duplicates_non_identical( - caplog: pytest.LogCaptureFixture, tmpdir + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local ) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") @@ -572,7 +577,9 @@ def test_delete_duplicates_non_identical( ] -def test_delete_duplicates_short_term(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +def test_delete_duplicates_short_term( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test removal of duplicated statistics.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 6aad3e440df5..383a0838430b 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -7,6 +7,7 @@ import sqlite3 from unittest.mock import MagicMock, Mock, patch from freezegun import freeze_time +import py import pytest from sqlalchemy import text from sqlalchemy.engine.result import ChunkedIteratorResult @@ -73,7 +74,7 @@ def test_recorder_bad_execute(hass_recorder: Callable[..., HomeAssistant]) -> No def test_validate_or_move_away_sqlite_database( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Ensure a malformed sqlite database is moved away.""" diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 467dc2961c66..22aa96f8e2f1 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -6,6 +6,7 @@ import importlib import sys from unittest.mock import patch +import py import pytest from sqlalchemy import create_engine, inspect from sqlalchemy.orm import Session @@ -51,7 +52,9 @@ def _create_engine_test(*args, **kwargs): return engine -async def test_migrate_times(caplog: pytest.LogCaptureFixture, tmpdir) -> None: +async def test_migrate_times( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: """Test we can migrate times.""" test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 77e5fa4aef8e..b60e072d2f15 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -5,6 +5,7 @@ import json from typing import Any, NamedTuple from unittest.mock import Mock, patch +import py import pytest from homeassistant.const import ( @@ -505,7 +506,7 @@ async def test_changing_delayed_written_data( } -async def test_saving_load_round_trip(tmpdir) -> None: +async def test_saving_load_round_trip(tmpdir: py.path.local) -> None: """Test saving and loading round trip.""" loop = asyncio.get_running_loop() hass = await async_test_home_assistant(loop) diff --git a/tests/helpers/test_storage_remove.py b/tests/helpers/test_storage_remove.py index b90d0df0d03a..21eabe80e45a 100644 --- a/tests/helpers/test_storage_remove.py +++ b/tests/helpers/test_storage_remove.py @@ -4,13 +4,15 @@ from datetime import timedelta import os from unittest.mock import patch +import py + from homeassistant.helpers import storage from homeassistant.util import dt from tests.common import async_fire_time_changed, async_test_home_assistant -async def test_removing_while_delay_in_progress(tmpdir) -> None: +async def test_removing_while_delay_in_progress(tmpdir: py.path.local) -> None: """Test removing while delay in progress.""" loop = asyncio.get_event_loop() diff --git a/tests/test_runner.py b/tests/test_runner.py index e4af1df2b800..f32321c578c3 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -3,6 +3,7 @@ import asyncio import threading from unittest.mock import patch +import py import pytest from homeassistant import core, runner @@ -28,7 +29,7 @@ async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None: ) -async def test_setup_and_run_hass(hass: HomeAssistant, tmpdir) -> None: +async def test_setup_and_run_hass(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we can setup and run.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -42,7 +43,7 @@ async def test_setup_and_run_hass(hass: HomeAssistant, tmpdir) -> None: assert mock_run.called -def test_run(hass: HomeAssistant, tmpdir) -> None: +def test_run(hass: HomeAssistant, tmpdir: py.path.local) -> None: """Test we can run.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -57,7 +58,9 @@ def test_run(hass: HomeAssistant, tmpdir) -> None: assert mock_run.called -def test_run_executor_shutdown_throws(hass: HomeAssistant, tmpdir) -> None: +def test_run_executor_shutdown_throws( + hass: HomeAssistant, tmpdir: py.path.local +) -> None: """Test we can run and we still shutdown if the executor shutdown throws.""" test_dir = tmpdir.mkdir("config") default_config = runner.RuntimeConfig(test_dir) @@ -79,7 +82,7 @@ def test_run_executor_shutdown_throws(hass: HomeAssistant, tmpdir) -> None: def test_run_does_not_block_forever_with_shielded_task( - hass: HomeAssistant, tmpdir, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test we can shutdown and not block forever.""" test_dir = tmpdir.mkdir("config") diff --git a/tests/util/test_file.py b/tests/util/test_file.py index 5934dc689f02..0b87985fe13c 100644 --- a/tests/util/test_file.py +++ b/tests/util/test_file.py @@ -3,13 +3,14 @@ import os from pathlib import Path from unittest.mock import patch +import py import pytest from homeassistant.util.file import WriteError, write_utf8_file, write_utf8_file_atomic @pytest.mark.parametrize("func", [write_utf8_file, write_utf8_file_atomic]) -def test_write_utf8_file_atomic_private(tmpdir, func) -> None: +def test_write_utf8_file_atomic_private(tmpdir: py.path.local, func) -> None: """Test files can be written as 0o600 or 0o644.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") @@ -25,7 +26,7 @@ def test_write_utf8_file_atomic_private(tmpdir, func) -> None: assert os.stat(test_file).st_mode & 0o777 == 0o600 -def test_write_utf8_file_fails_at_creation(tmpdir) -> None: +def test_write_utf8_file_fails_at_creation(tmpdir: py.path.local) -> None: """Test that failed creation of the temp file does not create an empty file.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") @@ -39,7 +40,7 @@ def test_write_utf8_file_fails_at_creation(tmpdir) -> None: def test_write_utf8_file_fails_at_rename( - tmpdir, caplog: pytest.LogCaptureFixture + tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test that if rename fails not not remove, we do not log the failed cleanup.""" test_dir = tmpdir.mkdir("files") @@ -56,7 +57,7 @@ def test_write_utf8_file_fails_at_rename( def test_write_utf8_file_fails_at_rename_and_remove( - tmpdir, caplog: pytest.LogCaptureFixture + tmpdir: py.path.local, caplog: pytest.LogCaptureFixture ) -> None: """Test that if rename and remove both fail, we log the failed cleanup.""" test_dir = tmpdir.mkdir("files") @@ -70,7 +71,7 @@ def test_write_utf8_file_fails_at_rename_and_remove( assert "File replacement cleanup failed" in caplog.text -def test_write_utf8_file_atomic_fails(tmpdir) -> None: +def test_write_utf8_file_atomic_fails(tmpdir: py.path.local) -> None: """Test OSError from write_utf8_file_atomic is rethrown as WriteError.""" test_dir = tmpdir.mkdir("files") test_file = Path(test_dir / "test.json") From aa72b4872553e59f3a5f30354c185f4f0d969cf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Mar 2023 23:22:21 -1000 Subject: [PATCH 0562/1058] Mark recorder system_health session read_only (#89842) --- homeassistant/components/recorder/system_health/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index da463d38610d..76542a0c1738 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -33,7 +33,7 @@ def async_register( def _get_db_stats(instance: Recorder, database_name: str) -> dict[str, Any]: """Get the stats about the database.""" db_stats: dict[str, Any] = {} - with session_scope(session=instance.get_session()) as session: + with session_scope(session=instance.get_session(), read_only=True) as session: if ( (dialect_name := instance.dialect_name) and (get_size := DIALECT_TO_GET_SIZE.get(dialect_name)) From abd91dd9345cb3e99d857ced485444901deb09cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 10:22:43 +0100 Subject: [PATCH 0563/1058] Ensure MockEntityPlatform shuts down after tests (#89849) --- tests/common.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/common.py b/tests/common.py index 8b9a9c240179..569813d221c6 100644 --- a/tests/common.py +++ b/tests/common.py @@ -37,6 +37,7 @@ from homeassistant.config import async_process_component_config from homeassistant.const import ( DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_CLOSE, + EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, @@ -757,7 +758,7 @@ class MockEntityPlatform(entity_platform.EntityPlatform): def __init__( self, - hass, + hass: HomeAssistant, logger=None, domain="test_domain", platform_name="test_platform", @@ -783,6 +784,11 @@ class MockEntityPlatform(entity_platform.EntityPlatform): entity_namespace=entity_namespace, ) + async def _async_on_stop(_: Event) -> None: + await self.async_shutdown() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_on_stop) + class MockToggleEntity(entity.ToggleEntity): """Provide a mock toggle device.""" From ab6e929443068070e628eacdda763896c644982b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 10:26:05 +0100 Subject: [PATCH 0564/1058] Fix EntityComponent lingering timer in helper tests (#89801) * Fix lingering timer in entity platform tests * Tweak * Fix entity and entity_component also * Remove async_shutdown * Adjust * Adjust --- tests/helpers/test_entity_component.py | 9 +++++++++ tests/helpers/test_entity_platform.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 018fb6c9372f..4c9847bb3d2d 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -168,6 +168,7 @@ async def test_set_entity_namespace_via_config(hass: HomeAssistant) -> None: async def test_extract_from_service_available_device(hass: HomeAssistant) -> None: """Test the extraction of entity from service and device is available.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [ MockEntity(name="test_1"), @@ -236,6 +237,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) -> None: """Test the extraction of everything from service.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) @@ -262,6 +264,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( ) -> None: """Test the extraction of non existing entities from service.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) @@ -280,6 +283,7 @@ async def test_extract_from_service_filter_out_non_existing_entities( async def test_extract_from_service_no_group_expand(hass: HomeAssistant) -> None: """Test not expanding a group.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities([MockEntity(entity_id="group.test_group")]) call = ServiceCall("test", "service", {"entity_id": ["group.test_group"]}) @@ -395,6 +399,7 @@ async def test_unload_entry_fails_if_never_loaded(hass: HomeAssistant) -> None: async def test_update_entity(hass: HomeAssistant) -> None: """Test that we can update an entity with the helper.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity = MockEntity() entity.async_write_ha_state = Mock() entity.async_update_ha_state = AsyncMock(return_value=None) @@ -422,6 +427,7 @@ async def test_set_service_race(hass: HomeAssistant) -> None: await async_setup_component(hass, "group", {}) component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) for _ in range(2): hass.async_create_task(component.async_add_entities([MockEntity()])) @@ -435,6 +441,7 @@ async def test_extract_all_omit_entity_id( ) -> None: """Test extract all with None and *.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) @@ -451,6 +458,7 @@ async def test_extract_all_use_match_all( ) -> None: """Test extract all with None and *.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="test_1"), MockEntity(name="test_2")] ) @@ -477,6 +485,7 @@ async def test_register_entity_service(hass: HomeAssistant) -> None: entity.async_called_by_service = appender component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities([entity]) component.async_register_entity_service( diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 7163461ae1ce..56872fe5b4b6 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -46,6 +46,7 @@ async def test_polling_only_updates_entities_it_should_poll( ) -> None: """Test the polling of only updated entities.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) no_poll_ent = MockEntity(should_poll=False) no_poll_ent.async_update = Mock() @@ -78,6 +79,7 @@ async def test_polling_disabled_by_config_entry(hass: HomeAssistant) -> None: async def test_polling_updates_entities_with_exception(hass: HomeAssistant) -> None: """Test the updated entities that not break with an exception.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) update_ok = [] update_err = [] @@ -115,6 +117,7 @@ async def test_polling_updates_entities_with_exception(hass: HomeAssistant) -> N async def test_update_state_adds_entities(hass: HomeAssistant) -> None: """Test if updating poll entities cause an entity to be added works.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) ent1 = MockEntity() ent2 = MockEntity(should_poll=True) @@ -134,6 +137,7 @@ async def test_update_state_adds_entities_with_update_before_add_true( ) -> None: """Test if call update before add to state machine.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) ent = MockEntity() ent.update = Mock(spec_set=True) @@ -150,6 +154,7 @@ async def test_update_state_adds_entities_with_update_before_add_false( ) -> None: """Test if not call update before add to state machine.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) ent = MockEntity() ent.update = Mock(spec_set=True) @@ -199,6 +204,7 @@ async def test_adding_entities_with_generator_and_thread_callback( it into an async context. """ component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) def create_entity(number: int) -> MockEntity: """Create entity helper.""" @@ -259,6 +265,7 @@ async def test_platform_error_slow_setup( async def test_updated_state_used_for_entity_id(hass: HomeAssistant) -> None: """Test that first update results used for entity ID generation.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) class MockEntityNameFetcher(MockEntity): """Mock entity that fetches a friendly name.""" @@ -407,6 +414,7 @@ async def test_raise_error_on_update(hass: HomeAssistant) -> None: """Test the add entity if they raise an error on update.""" updates = [] component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity1 = MockEntity(name="test_1") entity2 = MockEntity(name="test_2") @@ -431,6 +439,7 @@ async def test_raise_error_on_update(hass: HomeAssistant) -> None: async def test_async_remove_with_platform(hass: HomeAssistant) -> None: """Remove an entity from a platform.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity1 = MockEntity(name="test_1") await component.async_add_entities([entity1]) assert len(hass.states.async_entity_ids()) == 1 @@ -441,6 +450,7 @@ async def test_async_remove_with_platform(hass: HomeAssistant) -> None: async def test_async_remove_with_platform_update_finishes(hass: HomeAssistant) -> None: """Remove an entity when an update finishes after its been removed.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity1 = MockEntity(name="test_1") async def _delayed_update(*args, **kwargs): @@ -471,6 +481,7 @@ async def test_not_adding_duplicate_entities_with_unique_id( """ caplog.set_level(logging.ERROR) component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) ent1 = MockEntity(name="test1", unique_id="not_very_unique") await component.async_add_entities([ent1]) @@ -504,6 +515,7 @@ async def test_not_adding_duplicate_entities_with_unique_id( async def test_using_prescribed_entity_id(hass: HomeAssistant) -> None: """Test for using predefined entity ID.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(name="bla", entity_id="hello.world")] ) @@ -513,6 +525,7 @@ async def test_using_prescribed_entity_id(hass: HomeAssistant) -> None: async def test_using_prescribed_entity_id_with_unique_id(hass: HomeAssistant) -> None: """Test for amending predefined entity ID because currently exists.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities([MockEntity(entity_id="test_domain.world")]) await component.async_add_entities( @@ -527,6 +540,7 @@ async def test_using_prescribed_entity_id_which_is_registered( ) -> None: """Test not allowing predefined entity ID that already registered.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) # Register test_domain.world entity_registry.async_get_or_create( DOMAIN, "test", "1234", suggested_object_id="world" @@ -543,6 +557,7 @@ async def test_name_which_conflict_with_registered( ) -> None: """Test not generating conflicting entity ID based on name.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) # Register test_domain.world entity_registry.async_get_or_create( @@ -559,6 +574,7 @@ async def test_entity_with_name_and_entity_id_getting_registered( ) -> None: """Ensure that entity ID is used for registration.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(unique_id="1234", name="bla", entity_id="test_domain.world")] ) @@ -568,6 +584,7 @@ async def test_entity_with_name_and_entity_id_getting_registered( async def test_overriding_name_from_registry(hass: HomeAssistant) -> None: """Test that we can override a name via the Entity Registry.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) mock_registry( hass, { @@ -625,6 +642,7 @@ async def test_unique_id_conflict_has_priority_over_disabled_entity( ) -> None: """Test that an entity that is not unique has priority over a disabled entity.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) entity1 = MockEntity( name="test1", unique_id="not_very_unique", enabled_by_default=False ) @@ -1192,6 +1210,7 @@ async def test_device_info_change_to_no_url( async def test_entity_disabled_by_integration(hass: HomeAssistant) -> None: """Test entity disabled by integration.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) entity_default = MockEntity(unique_id="default") entity_disabled = MockEntity( @@ -1254,6 +1273,7 @@ async def test_entity_disabled_by_device(hass: HomeAssistant) -> None: async def test_entity_hidden_by_integration(hass: HomeAssistant) -> None: """Test entity hidden by integration.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) entity_default = MockEntity(unique_id="default") entity_hidden = MockEntity( @@ -1273,6 +1293,7 @@ async def test_entity_hidden_by_integration(hass: HomeAssistant) -> None: async def test_entity_info_added_to_entity_registry(hass: HomeAssistant) -> None: """Test entity info is written to entity registry.""" component = EntityComponent(_LOGGER, DOMAIN, hass, timedelta(seconds=20)) + await component.async_setup({}) entity_default = MockEntity( capability_attributes={"max": 100}, @@ -1323,6 +1344,7 @@ async def test_override_restored_entities( hass.states.async_set("test_domain.world", "unavailable", {"restored": True}) component = EntityComponent(_LOGGER, DOMAIN, hass) + await component.async_setup({}) await component.async_add_entities( [MockEntity(unique_id="1234", state="on", entity_id="test_domain.world")], True From ed0a0590538a41768535d7e0aded2da52a9d3af4 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Fri, 17 Mar 2023 21:59:29 +1100 Subject: [PATCH 0565/1058] Refactor entity manager code in geo_json_events integration (#89847) * moved entity manager * fix circular reference * simplify new entity signal --- .../geo_json_events/geo_location.py | 89 ++++--------------- .../components/geo_json_events/manager.py | 84 +++++++++++++++++ 2 files changed, 102 insertions(+), 71 deletions(-) create mode 100644 homeassistant/components/geo_json_events/manager.py diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 74951bc3a97f..2df049dd9cd2 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from datetime import datetime, timedelta +from datetime import timedelta import logging from typing import Any @@ -21,16 +21,13 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +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.typing import ConfigType, DiscoveryInfoType +from .manager import GeoJsonFeedEntityManager + _LOGGER = logging.getLogger(__name__) ATTR_EXTERNAL_ID = "external_id" @@ -67,8 +64,21 @@ async def async_setup_platform( radius_in_km: float = config[CONF_RADIUS] # Initialize the entity manager. manager = GeoJsonFeedEntityManager( - hass, async_add_entities, scan_interval, coordinates, url, radius_in_km + hass, scan_interval, coordinates, url, radius_in_km ) + + @callback + def async_add_geolocation( + feed_manager: GenericFeedManager, + external_id: str, + ) -> None: + """Add geolocation entity from feed.""" + new_entity = GeoJsonLocationEvent(feed_manager, external_id) + _LOGGER.debug("Adding geolocation %s", new_entity) + async_add_entities([new_entity], True) + + async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation) + await manager.async_init() async def start_feed_manager(event: Event) -> None: @@ -78,69 +88,6 @@ async def async_setup_platform( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) -class GeoJsonFeedEntityManager: - """Feed Entity Manager for GeoJSON feeds.""" - - def __init__( - self, - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - scan_interval: timedelta, - coordinates: tuple[float, float], - url: str, - radius_in_km: float, - ) -> None: - """Initialize the GeoJSON Feed Manager.""" - - self._hass = hass - websession = aiohttp_client.async_get_clientsession(hass) - self._feed_manager = GenericFeedManager( - websession, - self._generate_entity, - self._update_entity, - self._remove_entity, - coordinates, - url, - filter_radius=radius_in_km, - ) - self._async_add_entities = async_add_entities - self._scan_interval = scan_interval - - async def async_init(self) -> None: - """Schedule initial and regular updates based on configured time interval.""" - - async def update(event_time: datetime) -> None: - """Update.""" - await self.async_update() - - # Trigger updates at regular intervals. - async_track_time_interval(self._hass, update, self._scan_interval) - _LOGGER.debug("Feed entity manager initialized") - - async def async_update(self) -> None: - """Refresh data.""" - await self._feed_manager.update() - _LOGGER.debug("Feed entity manager updated") - - def get_entry(self, external_id: str) -> GenericFeedEntry | None: - """Get feed entry by external id.""" - return self._feed_manager.feed_entries.get(external_id) - - async def _generate_entity(self, external_id: str) -> None: - """Generate new entity.""" - new_entity = GeoJsonLocationEvent(self, external_id) - # Add new entities to HA. - self._async_add_entities([new_entity], True) - - async def _update_entity(self, external_id: str) -> None: - """Update entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") - - async def _remove_entity(self, external_id: str) -> None: - """Remove entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") - - class GeoJsonLocationEvent(GeolocationEvent): """Represents an external event with GeoJSON data.""" diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py new file mode 100644 index 000000000000..6c51e6dd7235 --- /dev/null +++ b/homeassistant/components/geo_json_events/manager.py @@ -0,0 +1,84 @@ +"""Entity manager for generic GeoJSON events.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from aio_geojson_generic_client import GenericFeedManager +from aio_geojson_generic_client.feed_entry import GenericFeedEntry + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +DOMAIN = "geo_json_events" + +_LOGGER = logging.getLogger(__name__) + + +class GeoJsonFeedEntityManager: + """Feed Entity Manager for GeoJSON feeds.""" + + def __init__( + self, + hass: HomeAssistant, + scan_interval: timedelta, + coordinates: tuple[float, float], + url: str, + radius_in_km: float, + ) -> None: + """Initialize the GeoJSON Feed Manager.""" + + self._hass = hass + websession = aiohttp_client.async_get_clientsession(hass) + self._feed_manager = GenericFeedManager( + websession, + self._generate_entity, + self._update_entity, + self._remove_entity, + coordinates, + url, + filter_radius=radius_in_km, + ) + self._scan_interval = scan_interval + self.signal_new_entity = ( + f"{DOMAIN}_new_geolocation_{coordinates}-{url}-{radius_in_km}" + ) + + async def async_init(self) -> None: + """Schedule initial and regular updates based on configured time interval.""" + + async def update(event_time: datetime) -> None: + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + async_track_time_interval(self._hass, update, self._scan_interval) + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self) -> None: + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") + + def get_entry(self, external_id: str) -> GenericFeedEntry | None: + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + async def _generate_entity(self, external_id: str) -> None: + """Generate new entity.""" + async_dispatcher_send( + self._hass, + self.signal_new_entity, + self, + external_id, + ) + + async def _update_entity(self, external_id: str) -> None: + """Update entity.""" + async_dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") + + async def _remove_entity(self, external_id: str) -> None: + """Remove entity.""" + async_dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") From cdb01146da1027dcda24321000a26036c17809ed Mon Sep 17 00:00:00 2001 From: lunmay <28674102+lunmay@users.noreply.github.com> Date: Fri, 17 Mar 2023 12:05:29 +0100 Subject: [PATCH 0566/1058] Fix misstype translation reference keynames (#89855) --- .../components/binary_sensor/strings.json | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index a0dc2020f01a..f2bbc72e7a5f 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -132,7 +132,7 @@ "name": "Carbon monoxide", "state": { "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "cold": { @@ -201,8 +201,8 @@ "motion": { "name": "Motion", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "moving": { @@ -215,8 +215,8 @@ "occupancy": { "name": "Occupancy", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "opening": { @@ -264,15 +264,15 @@ "smoke": { "name": "Smoke", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "sound": { "name": "Sound", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "update": { @@ -285,8 +285,8 @@ "vibration": { "name": "Vibration", "state": { - "off": "[key:component::binary_sensor::entity_component::gas::state::off%]", - "on": "[key:component::binary_sensor::entity_component::gas::state::on%]" + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, "window": { From f4de050904b276c3439d4aac3550a77a484be5b5 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 17 Mar 2023 13:27:05 +0100 Subject: [PATCH 0567/1058] Bump hass-nabucasa to 0.61.1 (#89864) --- 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 ce8377f18707..7f8dfca14480 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.61.0"] + "requirements": ["hass-nabucasa==0.61.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 17693b31f68d..566dcc61b27c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ ciso8601==2.3.0 cryptography==39.0.1 dbus-fast==1.84.1 fnvhash==0.1.0 -hass-nabucasa==0.61.0 +hass-nabucasa==0.61.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 diff --git a/requirements_all.txt b/requirements_all.txt index fc2fd2c26a86..781df707b0f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -868,7 +868,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.0 +hass-nabucasa==0.61.1 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 042f2dd4923d..69f545241c82 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -666,7 +666,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.0 +hass-nabucasa==0.61.1 # homeassistant.components.conversation hassil==1.0.6 From b1a3bfb298458257be442e6621ef92c781537033 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 13:30:06 +0100 Subject: [PATCH 0568/1058] Drop flake8 in favor of Ruff (#89863) --- .devcontainer/devcontainer.json | 1 - .github/workflows/ci.yaml | 49 ---------------------- .github/workflows/matchers/flake8.json | 30 ------------- .pre-commit-config.yaml | 14 ------- .vscode/tasks.json | 14 ------- Dockerfile.dev | 1 - homeassistant/components/light/__init__.py | 2 +- homeassistant/components/mqtt/discovery.py | 2 +- pyproject.toml | 9 ---- requirements_test_pre_commit.txt | 8 ---- script/lint | 4 -- script/lint_and_test.py | 17 ++------ setup.cfg | 29 ------------- 13 files changed, 5 insertions(+), 175 deletions(-) delete mode 100644 .github/workflows/matchers/flake8.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1711ab68fdee..042eb94b1954 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,7 +20,6 @@ "python.linting.enabled": true, "python.linting.pylintEnabled": true, "python.formatting.blackPath": "/usr/local/bin/black", - "python.linting.flake8Path": "/usr/local/bin/flake8", "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", "python.linting.mypyPath": "/usr/local/bin/mypy", diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4ac1075fcb20..f23b28f2c166 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -286,55 +286,6 @@ jobs: shopt -s globstar pre-commit run --hook-stage manual black --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - lint-flake8: - name: Check flake8 - runs-on: ubuntu-22.04 - needs: - - info - - pre-commit - steps: - - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.5.0 - id: python - with: - python-version: ${{ env.DEFAULT_PYTHON }} - check-latest: true - - name: Restore base Python virtual environment - id: cache-venv - uses: actions/cache/restore@v3.3.1 - with: - path: venv - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Restore pre-commit environment from cache - id: cache-precommit - uses: actions/cache/restore@v3.3.1 - with: - path: ${{ env.PRE_COMMIT_CACHE }} - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.info.outputs.pre-commit_cache_key }} - - name: Register flake8 problem matcher - run: | - echo "::add-matcher::.github/workflows/matchers/flake8.json" - - name: Run flake8 (fully) - if: needs.info.outputs.test_full_suite == 'true' - run: | - . venv/bin/activate - pre-commit run --hook-stage manual flake8 --all-files - - name: Run flake8 (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual flake8 --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} - lint-ruff: name: Check ruff runs-on: ubuntu-22.04 diff --git a/.github/workflows/matchers/flake8.json b/.github/workflows/matchers/flake8.json deleted file mode 100644 index e059a1cf5f74..000000000000 --- a/.github/workflows/matchers/flake8.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "flake8-error", - "severity": "error", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - }, - { - "owner": "flake8-warning", - "severity": "warning", - "pattern": [ - { - "regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$", - "file": 1, - "line": 2, - "column": 3, - "message": 4 - } - ] - } - ] -} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index af0d3b318e50..8cedb60b8bb3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,20 +36,6 @@ repos: - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/|homeassistant/generated/ - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - additional_dependencies: - - pycodestyle==2.10.0 - - pyflakes==3.0.1 - - flake8-docstrings==1.6.0 - - pydocstyle==6.2.3 - - flake8-comprehensions==3.10.1 - - flake8-noqa==1.3.0 - - mccabe==0.7.0 - exclude: docs/source/conf.py - stages: [manual] - repo: https://github.com/PyCQA/bandit rev: 1.7.4 hooks: diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 849716d7ba8c..7af7a426d62b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -42,20 +42,6 @@ }, "problemMatcher": [] }, - { - "label": "Flake8", - "type": "shell", - "command": "pre-commit run flake8 --all-files", - "group": { - "kind": "test", - "isDefault": true - }, - "presentation": { - "reveal": "always", - "panel": "new" - }, - "problemMatcher": [] - }, { "label": "Ruff", "type": "shell", diff --git a/Dockerfile.dev b/Dockerfile.dev index 863ac5690bc6..116446d18182 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -5,7 +5,6 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Uninstall pre-installed formatting and linting tools # They would conflict with our pinned versions RUN pipx uninstall black -RUN pipx uninstall flake8 RUN pipx uninstall pydocstyle RUN pipx uninstall pycodestyle RUN pipx uninstall mypy diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 2af959d22fe2..02f6e44a7008 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -405,7 +405,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: base["params"] = data return base - async def async_handle_light_on_service( + async def async_handle_light_on_service( # noqa: C901 light: LightEntity, call: ServiceCall ) -> None: """Handle turning a light on. diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index cf565b42390b..a764b24b2e8d 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -99,7 +99,7 @@ async def async_start( # noqa: C901 mqtt_integrations = {} @callback - def async_discovery_message_received(msg: ReceiveMessage) -> None: + def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 """Process the received message.""" mqtt_data.last_discovery = time.time() payload = msg.payload diff --git a/pyproject.toml b/pyproject.toml index 082ae3ef2ba8..3ee9bc7be5c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -292,15 +292,6 @@ keep-runtime-typing = true [tool.ruff.per-file-ignores] -# TODO: these files have functions that are too complex, but flake8's and ruff's -# complexity (and/or nested-function) handling differs; trying to add a noqa doesn't work -# because the flake8-noqa plugin then disagrees on whether there should be a C901 noqa -# on that line. So, for now, we just ignore C901s on these files as far as ruff is concerned. - -"homeassistant/components/light/__init__.py" = ["C901"] -"homeassistant/components/mqtt/discovery.py" = ["C901"] -"homeassistant/components/websocket_api/http.py" = ["C901"] - # Allow for main entry & scripts to write to stdout "homeassistant/__main__.py" = ["T201"] "homeassistant/scripts/*" = ["T201"] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 0b7c52f85dca..f64f422cc406 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -4,15 +4,7 @@ autoflake==2.0.0 bandit==1.7.4 black==23.1.0 codespell==2.2.2 -flake8-comprehensions==3.10.1 -flake8-docstrings==1.6.0 -flake8-noqa==1.3.0 -flake8==6.0.0 isort==5.12.0 -mccabe==0.7.0 -pycodestyle==2.10.0 -pydocstyle==6.2.3 -pyflakes==3.0.1 pyupgrade==3.3.1 ruff==0.0.256 yamllint==1.28.0 diff --git a/script/lint b/script/lint index 450733cecfd0..daafedb2297e 100755 --- a/script/lint +++ b/script/lint @@ -12,10 +12,6 @@ if [ -z "$files" ] ; then exit fi printf "%s\n" $files -echo "================" -echo "LINT with flake8" -echo "================" -pre-commit run flake8 --files $files echo "==============" echo "LINT with ruff" echo "==============" diff --git a/script/lint_and_test.py b/script/lint_and_test.py index 630e0eb996e1..5a3d448c1f4c 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -116,9 +116,9 @@ async def pylint(files): return res -async def _ruff_or_flake8(tool, files): - """Exec ruff or flake8.""" - _, log = await async_exec("pre-commit", "run", tool, "--files", *files) +async def ruff(files): + """Exec ruff.""" + _, log = await async_exec("pre-commit", "run", "ruff", "--files", *files) res = [] for line in log.splitlines(): line = line.split(":") @@ -129,23 +129,12 @@ async def _ruff_or_flake8(tool, files): return res -async def flake8(files): - """Exec flake8.""" - return await _ruff_or_flake8("flake8", files) - - -async def ruff(files): - """Exec ruff.""" - return await _ruff_or_flake8("ruff", files) - - async def lint(files): """Perform lint.""" files = [file for file in files if os.path.isfile(file)] res = sorted( itertools.chain( *await asyncio.gather( - flake8(files), pylint(files), ruff(files), ) diff --git a/setup.cfg b/setup.cfg index 323c4c10d266..29713b6df46c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,32 +3,3 @@ [metadata] url = https://www.home-assistant.io/ - -[flake8] -exclude = .venv,.git,docs,venv,bin,lib,deps,build -max-complexity = 25 -doctests = True -# To work with Black -# E501: line too long -# W503: Line break occurred before a binary operator -# E203: Whitespace before ':' -# D202 No blank lines allowed after function docstring -# W504 line break after binary operator -ignore = - E501, - W503, - E203, - D202, - W504 -noqa-require-code = True - -# Ignores, that are currently caused by mismatching configurations -# between ruff and flake8 configurations. Once ruff becomes permanent flake8 -# will be removed, including these ignores below. -# In case we decide not to continue with ruff, we should remove these -# and probably need to clean up a couple of noqa comments. -per-file-ignores = - homeassistant/config.py:NQA102 - tests/components/august/mocks.py:NQA102 - tests/components/tts/conftest.py:NQA102 - tests/helpers/test_icon.py:NQA102 From a7a972fe964e50a0e00e969e8de133c330f18156 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 13:43:16 +0100 Subject: [PATCH 0569/1058] Upgrade pytest-xdist to 3.2.1 (#89857) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 7601326cd56f..e2db9d1e9f73 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,7 +28,7 @@ pytest-sugar==0.9.6 pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 -pytest-xdist==3.2.0 +pytest-xdist==3.2.1 pytest==7.2.2 requests_mock==1.10.0 respx==0.20.1 From a15c45dbfe385b17b8870cee02f67d9c8667046c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 14:17:05 +0100 Subject: [PATCH 0570/1058] Drop pyupgrade in favor of Ruff (#89865) --- .github/workflows/ci.yaml | 13 ------------- .pre-commit-config.yaml | 6 ------ requirements_test_pre_commit.txt | 1 - 3 files changed, 20 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f23b28f2c166..d0dafda42128 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -407,19 +407,6 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} - - name: Run pyupgrade (fully) - if: needs.info.outputs.test_full_suite == 'true' - run: | - . venv/bin/activate - pre-commit run --hook-stage manual pyupgrade --all-files --show-diff-on-failure - - name: Run pyupgrade (partially) - if: needs.info.outputs.test_full_suite == 'false' - shell: bash - run: | - . venv/bin/activate - shopt -s globstar - pre-commit run --hook-stage manual pyupgrade --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*} --show-diff-on-failure - - name: Register yamllint problem matcher run: | echo "::add-matcher::.github/workflows/matchers/yamllint.json" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8cedb60b8bb3..155d966e0fe5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,12 +5,6 @@ repos: - id: ruff args: - --fix - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 - hooks: - - id: pyupgrade - args: [--py310-plus] - stages: [manual] - repo: https://github.com/PyCQA/autoflake rev: v2.0.0 hooks: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index f64f422cc406..410d74c9e380 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -5,6 +5,5 @@ bandit==1.7.4 black==23.1.0 codespell==2.2.2 isort==5.12.0 -pyupgrade==3.3.1 ruff==0.0.256 yamllint==1.28.0 From 5657fcd1e8c89228525c32bcc4299b5169532244 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 18:52:19 +0100 Subject: [PATCH 0571/1058] Add state attribute translations for Number (#89881) --- homeassistant/components/number/strings.json | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index d265b84c740e..46db471305c3 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -7,7 +7,26 @@ }, "entity_component": { "_": { - "name": "[%key:component::number::title%]" + "name": "[%key:component::number::title%]", + "state_attributes": { + "max": { + "name": "Maximum" + }, + "min": { + "name": "Minimum" + }, + "mode": { + "name": "Mode", + "state": { + "auto": "Automatic", + "box": "Box", + "slider": "Slider" + } + }, + "step": { + "name": "Step" + } + } }, "apparent_power": { "name": "[%key:component::sensor::entity_component::apparent_power::name%]" From db5a7b0e5e64e6733a71a518e41d83c541f33cc4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 18:52:34 +0100 Subject: [PATCH 0572/1058] Add translations for Geolocation (#89880) --- .../components/geo_location/strings.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 homeassistant/components/geo_location/strings.json diff --git a/homeassistant/components/geo_location/strings.json b/homeassistant/components/geo_location/strings.json new file mode 100644 index 000000000000..4678790b520e --- /dev/null +++ b/homeassistant/components/geo_location/strings.json @@ -0,0 +1,19 @@ +{ + "title": "Geolocation", + "entity_component": { + "_": { + "name": "[%key:component::geo_location::title%]", + "state_attributes": { + "latitude": { + "name": "Latitude" + }, + "longitude": { + "name": "Longitude" + }, + "source": { + "name": "Source" + } + } + } + } +} From e402e733a0e6850e060a5f37c511844f33e53a26 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 18:52:59 +0100 Subject: [PATCH 0573/1058] Add translations for Image processing (#89879) --- .../components/image_processing/strings.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/image_processing/strings.json b/homeassistant/components/image_processing/strings.json index b635fb6aaeaf..861a2acc1f11 100644 --- a/homeassistant/components/image_processing/strings.json +++ b/homeassistant/components/image_processing/strings.json @@ -1 +1,16 @@ -{ "title": "Image processing" } +{ + "title": "Image processing", + "entity_component": { + "_": { + "name": "[%key:component::image_processing::title%]", + "state_attributes": { + "faces": { + "name": "Faces" + }, + "total_faces": { + "name": "Total faces" + } + } + } + } +} From 95515fbe789bc5111de3ca85776fec3a45f518f3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 19:25:20 +0100 Subject: [PATCH 0574/1058] Improve/extend state translations for Alarm Control Panel (#89872) --- .../components/alarm_control_panel/strings.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index f055d6646b6e..6b01cab2becc 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -44,13 +44,21 @@ }, "state_attributes": { "code_format": { - "name": "Code format" + "name": "Code format", + "state": { + "text": "Text", + "number": "Number" + } }, "changed_by": { "name": "Changed by" }, "code_arm_required": { - "name": "Code for arming required" + "name": "Code for arming", + "state": { + "true": "Required", + "false": "Not required" + } } } } From b403a96ea085d62abbdc4a8f45643f99f37022f1 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Fri, 17 Mar 2023 16:10:37 -0400 Subject: [PATCH 0575/1058] Bump screenlogicpy to v0.8.2 (#89832) --- .../components/screenlogic/__init__.py | 32 +++++++------------ .../components/screenlogic/entity.py | 11 ++++++- .../components/screenlogic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 5838031dc63a..6662c20ad4f6 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -10,7 +10,6 @@ from screenlogicpy.const import ( SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT, - ScreenLogicWarning, ) from homeassistant.config_entries import ConfigEntry @@ -52,8 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await gateway.async_connect(**connect_info) except ScreenLogicError as ex: - _LOGGER.error("Error while connecting to the gateway %s: %s", connect_info, ex) - raise ConfigEntryNotReady from ex + raise ConfigEntryNotReady(ex.msg) from ex coordinator = ScreenlogicDataUpdateCoordinator( hass, config_entry=entry, gateway=gateway @@ -157,25 +155,17 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Fetch data from the Screenlogic gateway.""" - try: - await self._async_update_configured_data() - except (ScreenLogicError, ScreenLogicWarning) as ex: - _LOGGER.warning("Update error - attempting reconnect: %s", ex) - await self._async_reconnect_update_data() - - return None - - async def _async_reconnect_update_data(self) -> None: - """Attempt to reconnect to the gateway and fetch data.""" assert self.config_entry is not None try: - # Clean up the previous connection as we're about to create a new one - await self.gateway.async_disconnect() - - connect_info = await async_get_connect_info(self.hass, self.config_entry) - await self.gateway.async_connect(**connect_info) + if not self.gateway.is_connected: + connect_info = await async_get_connect_info( + self.hass, self.config_entry + ) + await self.gateway.async_connect(**connect_info) await self._async_update_configured_data() - - except (ScreenLogicError, ScreenLogicWarning) as ex: - raise UpdateFailed(ex) from ex + except ScreenLogicError as ex: + if self.gateway.is_connected: + await self.gateway.async_disconnect() + raise UpdateFailed(ex.msg) from ex + return None diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 4ea23395c5a2..eb006b553671 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -101,21 +101,30 @@ class ScreenLogicPushEntity(ScreenlogicEntity): """Initialize the entity.""" super().__init__(coordinator, data_key, enabled) self._update_message_code = message_code + self._last_update_success = True @callback def _async_data_updated(self) -> None: """Handle data updates.""" + self._last_update_success = self.coordinator.last_update_success self.async_write_ha_state() async def async_added_to_hass(self) -> None: """When entity is added to hass.""" - + await super().async_added_to_hass() self.async_on_remove( await self.gateway.async_subscribe_client( self._async_data_updated, self._update_message_code ) ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # For push entities, only take updates from the coordinator if availability changes. + if self.coordinator.last_update_success != self._last_update_success: + self._async_data_updated() + class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Base class for all ScreenLogic switch and light entities.""" diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 977ef59f9e7f..5b8b83694274 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.7.2"] + "requirements": ["screenlogicpy==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 781df707b0f1..6e834d05471e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2303,7 +2303,7 @@ satel_integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.7.2 +screenlogicpy==0.8.2 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69f545241c82..30bb11b7456e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1636,7 +1636,7 @@ samsungtvws[async,encrypted]==2.5.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.7.2 +screenlogicpy==0.8.2 # homeassistant.components.backup securetar==2022.2.0 From fd5b57ae6c8ff68a9f86cae45657237cbf57d1d2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 17 Mar 2023 21:37:41 +0100 Subject: [PATCH 0576/1058] Drop autoflake in favor of Ruff (#89874) --- .pre-commit-config.yaml | 8 -------- requirements_test_pre_commit.txt | 1 - 2 files changed, 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 155d966e0fe5..fd196f19db3b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,14 +5,6 @@ repos: - id: ruff args: - --fix - - repo: https://github.com/PyCQA/autoflake - rev: v2.0.0 - hooks: - - id: autoflake - args: - - --in-place - - --remove-all-unused-imports - stages: [manual] - repo: https://github.com/psf/black rev: 23.1.0 hooks: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 410d74c9e380..a1faadfea4ac 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -autoflake==2.0.0 bandit==1.7.4 black==23.1.0 codespell==2.2.2 From 377dff5ee4d15e413665945c133eef3ef70fd460 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 10:45:58 -1000 Subject: [PATCH 0577/1058] Ensure all recorder session executes use retries or the execute helper (#89888) --- homeassistant/components/recorder/const.py | 6 +++ homeassistant/components/recorder/core.py | 40 ++++++++++++------- .../recorder/table_managers/states_meta.py | 11 ++++- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index effecf15a8b8..d72666f76b24 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -43,6 +43,12 @@ KEEPALIVE_TIME = 30 EXCLUDE_ATTRIBUTES = f"{DOMAIN}_exclude_attributes_by_domain" +STATISTICS_ROWS_SCHEMA_VERSION = 23 +CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 +EVENT_TYPE_IDS_SCHEMA_VERSION = 37 +STATES_META_SCHEMA_VERSION = 38 + + class SupportedDialect(StrEnum): """Supported dialects.""" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index db44baee06c2..8d1e87086576 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -41,8 +41,10 @@ from homeassistant.util.enum import try_parse_enum from . import migration, statistics from .const import ( + CONTEXT_ID_AS_BINARY_SCHEMA_VERSION, DB_WORKER_PREFIX, DOMAIN, + EVENT_TYPE_IDS_SCHEMA_VERSION, KEEPALIVE_TIME, MARIADB_PYMYSQL_URL_PREFIX, MARIADB_URL_PREFIX, @@ -50,6 +52,8 @@ from .const import ( MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, SQLITE_URL_PREFIX, + STATES_META_SCHEMA_VERSION, + STATISTICS_ROWS_SCHEMA_VERSION, SupportedDialect, ) from .db_schema import ( @@ -108,6 +112,7 @@ from .util import ( build_mysqldb_conv, dburl_to_path, end_incomplete_runs, + execute_stmt_lambda_element, is_second_sunday, move_away_broken_database, session_scope, @@ -688,24 +693,28 @@ class Recorder(threading.Thread): # since we want the frontend queries to avoid a thundering # herd of queries to find the statistics meta data if # there are a lot of statistics graphs on the frontend. - if self.schema_version >= 23: + if self.schema_version >= STATISTICS_ROWS_SCHEMA_VERSION: self.statistics_meta_manager.load(session) if ( - self.schema_version < 36 - or session.execute(has_events_context_ids_to_migrate()).scalar() + self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION + or execute_stmt_lambda_element( + session, has_events_context_ids_to_migrate() + ) ): self.queue_task(StatesContextIDMigrationTask()) if ( - self.schema_version < 36 - or session.execute(has_states_context_ids_to_migrate()).scalar() + self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION + or execute_stmt_lambda_element( + session, has_states_context_ids_to_migrate() + ) ): self.queue_task(EventsContextIDMigrationTask()) if ( - self.schema_version < 37 - or session.execute(has_event_type_to_migrate()).scalar() + self.schema_version < EVENT_TYPE_IDS_SCHEMA_VERSION + or execute_stmt_lambda_element(session, has_event_type_to_migrate()) ): self.queue_task(EventTypeIDMigrationTask()) else: @@ -713,8 +722,8 @@ class Recorder(threading.Thread): self.event_type_manager.active = True if ( - self.schema_version < 38 - or session.execute(has_entity_ids_to_migrate()).scalar() + self.schema_version < STATES_META_SCHEMA_VERSION + or execute_stmt_lambda_element(session, has_entity_ids_to_migrate()) ): self.queue_task(EntityIDMigrationTask()) else: @@ -1002,7 +1011,7 @@ class Recorder(threading.Thread): if states_meta_manager.active: dbstate.entity_id = None - self.event_session.add(dbstate) + session.add(dbstate) def _handle_database_error(self, err: Exception) -> bool: """Handle a database error that may result in moving away the corrupt db.""" @@ -1015,9 +1024,9 @@ class Recorder(threading.Thread): return False def _event_session_has_pending_writes(self) -> bool: - return bool( - self.event_session and (self.event_session.new or self.event_session.dirty) - ) + """Return True if there are pending writes in the event session.""" + session = self.event_session + return bool(session and (session.new or session.dirty)) def _commit_event_session_or_retry(self) -> None: """Commit the event session if there is work to do.""" @@ -1043,9 +1052,10 @@ class Recorder(threading.Thread): def _commit_event_session(self) -> None: assert self.event_session is not None + session = self.event_session self._commits_without_expire += 1 - self.event_session.commit() + session.commit() # We just committed the state attributes to the database # and we now know the attributes_ids. We can save # many selects for matching attributes by loading them @@ -1061,7 +1071,7 @@ class Recorder(threading.Thread): # do it after EXPIRE_AFTER_COMMITS commits if self._commits_without_expire >= EXPIRE_AFTER_COMMITS: self._commits_without_expire = 0 - self.event_session.expire_all() + session.expire_all() def _handle_sqlite_corruption(self) -> None: """Handle the sqlite3 database being corrupt.""" diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index 76b748d46972..c9c1ba902809 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -1,7 +1,7 @@ """Support managing StatesMeta.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from typing import TYPE_CHECKING, cast from sqlalchemy.orm.session import Session @@ -63,7 +63,14 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): This call is always thread-safe. """ with session.no_autoflush: - return dict(tuple(session.execute(find_all_states_metadata_ids()))) # type: ignore[arg-type] + return dict( + cast( + Sequence[tuple[int, str]], + execute_stmt_lambda_element( + session, find_all_states_metadata_ids() + ), + ) + ) def get_many( self, entity_ids: Iterable[str], session: Session, from_recorder: bool From 469dbec0899e0228699b34c1072a39f068960851 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Mar 2023 22:14:24 +0100 Subject: [PATCH 0578/1058] Add type hints to plex data (#89221) * Add type hints to plex data * Rename method --- homeassistant/components/plex/__init__.py | 61 +++++++++++-------- homeassistant/components/plex/config_flow.py | 4 +- homeassistant/components/plex/const.py | 13 ++-- homeassistant/components/plex/helpers.py | 36 +++++++++++ .../components/plex/media_browser.py | 6 +- homeassistant/components/plex/media_player.py | 6 +- homeassistant/components/plex/sensor.py | 5 +- homeassistant/components/plex/server.py | 8 ++- homeassistant/components/plex/services.py | 5 +- homeassistant/components/plex/view.py | 5 +- 10 files changed, 98 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 78e8fac23a9b..559f4440aefe 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -35,8 +35,6 @@ from .const import ( CONF_SERVER_IDENTIFIER, DISPATCHERS, DOMAIN, - GDM_DEBOUNCER, - GDM_SCANNER, PLATFORMS, PLATFORMS_COMPLETED, PLEX_SERVER_CONFIG, @@ -47,6 +45,7 @@ from .const import ( WEBSOCKETS, ) from .errors import ShouldUpdateConfigEntry +from .helpers import PlexData, get_plex_data from .media_browser import browse_media from .server import PlexServer from .services import async_setup_services @@ -62,7 +61,7 @@ def is_plex_media_id(media_content_id): async def async_browse_media(hass, media_content_type, media_content_id, platform=None): """Browse Plex media.""" - plex_server = next(iter(hass.data[DOMAIN][SERVERS].values()), None) + plex_server = next(iter(get_plex_data(hass)[SERVERS].values()), None) if not plex_server: raise BrowseError("No Plex servers available") is_internal = is_internal_request(hass) @@ -80,22 +79,13 @@ async def async_browse_media(hass, media_content_type, media_content_id, platfor async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Plex component.""" - hass.data.setdefault( - DOMAIN, - {SERVERS: {}, DISPATCHERS: {}, WEBSOCKETS: {}, PLATFORMS_COMPLETED: {}}, - ) - - await async_setup_services(hass) - - hass.http.register_view(PlexImageView()) - - gdm = hass.data[DOMAIN][GDM_SCANNER] = GDM() + gdm = GDM() def gdm_scan(): _LOGGER.debug("Scanning for GDM clients") gdm.scan(scan_for_clients=True) - hass.data[DOMAIN][GDM_DEBOUNCER] = Debouncer[None]( + debouncer = Debouncer[None]( hass, _LOGGER, cooldown=10, @@ -103,6 +93,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: function=gdm_scan, ).async_call + hass_data = PlexData( + servers={}, + dispatchers={}, + websockets={}, + platforms_completed={}, + gdm_scanner=gdm, + gdm_debouncer=debouncer, + ) + hass.data.setdefault(DOMAIN, hass_data) + + await async_setup_services(hass) + + hass.http.register_view(PlexImageView()) + return True @@ -161,8 +165,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Connected to: %s (%s)", plex_server.friendly_name, plex_server.url_in_use ) server_id = plex_server.machine_identifier - hass.data[DOMAIN][SERVERS][server_id] = plex_server - hass.data[DOMAIN][PLATFORMS_COMPLETED][server_id] = set() + hass_data = get_plex_data(hass) + hass_data[SERVERS][server_id] = plex_server + hass_data[PLATFORMS_COMPLETED][server_id] = set() entry.add_update_listener(async_options_updated) @@ -171,8 +176,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id), plex_server.async_update_platforms, ) - hass.data[DOMAIN][DISPATCHERS].setdefault(server_id, []) - hass.data[DOMAIN][DISPATCHERS][server_id].append(unsub) + hass_data[DISPATCHERS].setdefault(server_id, []) + hass_data[DISPATCHERS][server_id].append(unsub) @callback def plex_websocket_callback(msgtype, data, error): @@ -213,11 +218,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=session, verify_ssl=verify_ssl, ) - hass.data[DOMAIN][WEBSOCKETS][server_id] = websocket + hass_data[WEBSOCKETS][server_id] = websocket def start_websocket_session(platform): - hass.data[DOMAIN][PLATFORMS_COMPLETED][server_id].add(platform) - if hass.data[DOMAIN][PLATFORMS_COMPLETED][server_id] == PLATFORMS: + hass_data[PLATFORMS_COMPLETED][server_id].add(platform) + if hass_data[PLATFORMS_COMPLETED][server_id] == PLATFORMS: hass.loop.create_task(websocket.listen()) def close_websocket_session(_): @@ -226,7 +231,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unsub = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, close_websocket_session ) - hass.data[DOMAIN][DISPATCHERS][server_id].append(unsub) + hass_data[DISPATCHERS][server_id].append(unsub) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -263,16 +268,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" server_id = entry.data[CONF_SERVER_IDENTIFIER] - websocket = hass.data[DOMAIN][WEBSOCKETS].pop(server_id) + hass_data = get_plex_data(hass) + websocket = hass_data[WEBSOCKETS].pop(server_id) websocket.close() - dispatchers = hass.data[DOMAIN][DISPATCHERS].pop(server_id) + dispatchers = hass_data[DISPATCHERS].pop(server_id) for unsub in dispatchers: unsub() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][SERVERS].pop(server_id) + hass_data[SERVERS].pop(server_id) return unload_ok @@ -281,9 +287,10 @@ async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None """Triggered by config entry options updates.""" server_id = entry.data[CONF_SERVER_IDENTIFIER] + hass_data = get_plex_data(hass) # Guard incomplete setup during reauth flows - if server_id in hass.data[DOMAIN][SERVERS]: - hass.data[DOMAIN][SERVERS][server_id].options = entry.options + if server_id in hass_data[SERVERS]: + hass_data[SERVERS][server_id].options = entry.options @callback diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 1ebe439ff7cc..10ae380a08aa 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -49,13 +49,13 @@ from .const import ( DOMAIN, MANUAL_SETUP_STRING, PLEX_SERVER_CONFIG, - SERVERS, X_PLEX_DEVICE_NAME, X_PLEX_PLATFORM, X_PLEX_PRODUCT, X_PLEX_VERSION, ) from .errors import NoServersFound, ServerNotSpecified +from .helpers import get_plex_server from .server import PlexServer HEADER_FRONTEND_BASE = "HA-Frontend-Base" @@ -360,7 +360,7 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_plex_mp_settings(self, user_input=None): """Manage the Plex media_player options.""" - plex_server = self.hass.data[DOMAIN][SERVERS][self.server_id] + plex_server = get_plex_server(self.hass, self.server_id) if user_input is not None: self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] = user_input[ diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index dea976f46dd5..3f761c9748a5 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -1,5 +1,6 @@ """Constants for the Plex component.""" from datetime import timedelta +from typing import Final from homeassistant.const import Platform, __version__ @@ -16,14 +17,14 @@ PLEXTV_THROTTLE = 60 CLIENT_SCAN_INTERVAL = timedelta(minutes=10) DEBOUNCE_TIMEOUT = 1 -DISPATCHERS = "dispatchers" -GDM_DEBOUNCER = "gdm_debouncer" -GDM_SCANNER = "gdm_scanner" +DISPATCHERS: Final = "dispatchers" +GDM_DEBOUNCER: Final = "gdm_debouncer" +GDM_SCANNER: Final = "gdm_scanner" PLATFORMS = frozenset([Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR]) -PLATFORMS_COMPLETED = "platforms_completed" +PLATFORMS_COMPLETED: Final = "platforms_completed" PLAYER_SOURCE = "player_source" -SERVERS = "servers" -WEBSOCKETS = "websockets" +SERVERS: Final = "servers" +WEBSOCKETS: Final = "websockets" PLEX_SERVER_CONFIG = "server_config" diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py index 6a0f0780e009..6a334c5ff611 100644 --- a/homeassistant/components/plex/helpers.py +++ b/homeassistant/components/plex/helpers.py @@ -1,4 +1,40 @@ """Helper methods for common Plex integration operations.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import TYPE_CHECKING, Any, TypedDict + +from plexapi.gdm import GDM +from plexwebsocket import PlexWebsocket + +from homeassistant.const import Platform +from homeassistant.core import CALLBACK_TYPE, HomeAssistant + +from .const import DOMAIN, SERVERS + +if TYPE_CHECKING: + from . import PlexServer + + +class PlexData(TypedDict): + """Typed description of plex data stored in `hass.data`.""" + + servers: dict[str, PlexServer] + dispatchers: dict[str, list[CALLBACK_TYPE]] + websockets: dict[str, PlexWebsocket] + platforms_completed: dict[str, set[Platform]] + gdm_scanner: GDM + gdm_debouncer: Callable[[], Coroutine[Any, Any, None]] + + +def get_plex_data(hass: HomeAssistant) -> PlexData: + """Get typed data from hass.data.""" + return hass.data[DOMAIN] + + +def get_plex_server(hass: HomeAssistant, server_id: str) -> PlexServer: + """Get Plex server from hass.data.""" + return get_plex_data(hass)[SERVERS][server_id] def pretty_title(media, short_name=False): diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index 95ad3f39c706..d3a0cc0fb2e7 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -7,7 +7,7 @@ from homeassistant.components.media_player import BrowseError, BrowseMedia, Medi from .const import DOMAIN, SERVERS from .errors import MediaNotFound -from .helpers import pretty_title +from .helpers import get_plex_data, get_plex_server, pretty_title class UnknownMediaType(BrowseError): @@ -42,7 +42,7 @@ def browse_media( # noqa: C901 if media_content_id: url = URL(media_content_id) server_id = url.host - plex_server = hass.data[DOMAIN][SERVERS][server_id] + plex_server = get_plex_server(hass, server_id) if media_content_type == "hub": _, hub_location, hub_identifier = url.parts elif media_content_type in ["library", "server"] and len(url.parts) > 2: @@ -294,7 +294,7 @@ def root_payload(hass, is_internal, platform=None): """Return root payload for Plex.""" children = [] - for server_id in hass.data[DOMAIN][SERVERS]: + for server_id in get_plex_data(hass)[SERVERS]: children.append( browse_media( hass, diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 13422beec4f7..6fe6d641a835 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -40,9 +40,9 @@ from .const import ( PLEX_UPDATE_MEDIA_PLAYER_SESSION_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, - SERVERS, TRANSIENT_DEVICE_MODELS, ) +from .helpers import get_plex_data, get_plex_server from .media_browser import browse_media from .services import process_plex_payload @@ -85,7 +85,7 @@ async def async_setup_entry( unsub = async_dispatcher_connect( hass, PLEX_NEW_MP_SIGNAL.format(server_id), async_new_media_players ) - hass.data[DOMAIN][DISPATCHERS][server_id].append(unsub) + get_plex_data(hass)[DISPATCHERS][server_id].append(unsub) _LOGGER.debug("New entity listener created") @@ -94,7 +94,7 @@ def _async_add_entities(hass, registry, async_add_entities, server_id, new_entit """Set up Plex media_player entities.""" _LOGGER.debug("New entities: %s", new_entities) entities = [] - plexserver = hass.data[DOMAIN][SERVERS][server_id] + plexserver = get_plex_server(hass, server_id) for entity_params in new_entities: plex_mp = PlexMediaPlayer(plexserver, **entity_params) entities.append(plex_mp) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index f4a2ac6e03ab..3b66fe0cf6d8 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -20,9 +20,8 @@ from .const import ( NAME_FORMAT, PLEX_UPDATE_LIBRARY_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, - SERVERS, ) -from .helpers import pretty_title +from .helpers import get_plex_server, pretty_title LIBRARY_ATTRIBUTE_TYPES = { "artist": ["artist", "album"], @@ -57,7 +56,7 @@ async def async_setup_entry( ) -> None: """Set up Plex sensor from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] - plexserver = hass.data[DOMAIN][SERVERS][server_id] + plexserver = get_plex_server(hass, server_id) sensors = [PlexSensor(hass, plexserver)] def create_library_sensors(): diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 827712889e14..9684c79792a0 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -1,4 +1,6 @@ """Shared class to maintain Plex server instances.""" +from __future__ import annotations + import logging import ssl import time @@ -27,7 +29,6 @@ from .const import ( CONF_USE_EPISODE_ART, DEBOUNCE_TIMEOUT, DEFAULT_VERIFY_SSL, - DOMAIN, GDM_DEBOUNCER, GDM_SCANNER, PLAYER_SOURCE, @@ -47,6 +48,7 @@ from .errors import ( ServerNotSpecified, ShouldUpdateConfigEntry, ) +from .helpers import get_plex_data from .media_search import search_media from .models import PlexSession @@ -316,7 +318,7 @@ class PlexServer: """Update the platform entities.""" _LOGGER.debug("Updating devices") - await self.hass.data[DOMAIN][GDM_DEBOUNCER]() + await get_plex_data(self.hass)[GDM_DEBOUNCER]() available_clients = {} ignored_clients = set() @@ -429,7 +431,7 @@ class PlexServer: def connect_new_clients(): """Create connections to newly discovered clients.""" - for gdm_entry in self.hass.data[DOMAIN][GDM_SCANNER].entries: + for gdm_entry in get_plex_data(self.hass)[GDM_SCANNER].entries: machine_identifier = gdm_entry["data"]["Resource-Identifier"] if machine_identifier in self._client_device_cache: client = self._client_device_cache[machine_identifier] diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 46c0df886119..62576471448d 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -19,6 +19,7 @@ from .const import ( SERVICE_SCAN_CLIENTS, ) from .errors import MediaNotFound +from .helpers import get_plex_data from .models import PlexMediaSearchResult from .server import PlexServer @@ -41,7 +42,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: " Service calls will still work for now but the service will be removed in" " a future release" ) - for server_id in hass.data[DOMAIN][SERVERS]: + for server_id in get_plex_data(hass)[SERVERS]: async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) hass.services.async_register( @@ -84,7 +85,7 @@ def get_plex_server( """Retrieve a configured Plex server by name.""" if DOMAIN not in hass.data: raise HomeAssistantError("Plex integration not configured") - servers: dict[str, PlexServer] = hass.data[DOMAIN][SERVERS] + servers: dict[str, PlexServer] = get_plex_data(hass)[SERVERS] if not servers: raise HomeAssistantError("No Plex servers available") diff --git a/homeassistant/components/plex/view.py b/homeassistant/components/plex/view.py index a2c31f17eb1d..ba883883ddc8 100644 --- a/homeassistant/components/plex/view.py +++ b/homeassistant/components/plex/view.py @@ -11,7 +11,8 @@ from aiohttp.typedefs import LooseHeaders from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.media_player import async_fetch_image -from .const import DOMAIN, SERVERS +from .const import SERVERS +from .helpers import get_plex_data _LOGGER = logging.getLogger(__name__) @@ -33,7 +34,7 @@ class PlexImageView(HomeAssistantView): return web.Response(status=HTTPStatus.UNAUTHORIZED) hass = request.app["hass"] - if (server := hass.data[DOMAIN][SERVERS].get(server_id)) is None: + if (server := get_plex_data(hass)[SERVERS].get(server_id)) is None: return web.Response(status=HTTPStatus.NOT_FOUND) if (image_url := server.thumbnail_cache.get(media_content_id)) is None: From 5f22796b38b4d249a7fa42e5d8692740cf07fc42 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 17 Mar 2023 22:45:15 +0100 Subject: [PATCH 0579/1058] Refactor imap coordinator (#89759) * Warn if the previous push wait task it taking longer than the update interval * refactor * Call _async_fetch_number_of_messages first * Add cleanup in case fetching fails * mypy * Set sensor to unknown if an error occured. * Handling invalid auth an reraise when needed * Handle invalid folder as setup error * Close IMAP stream before logout at cleanup --------- Co-authored-by: J. Nick Koston --- homeassistant/components/imap/__init__.py | 24 ++- homeassistant/components/imap/coordinator.py | 202 ++++++++++++++----- homeassistant/components/imap/sensor.py | 23 ++- 3 files changed, 185 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 7e582aa04d4e..468181be5f7c 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -15,7 +15,11 @@ from homeassistant.exceptions import ( ) from .const import DOMAIN -from .coordinator import ImapDataUpdateCoordinator, connect_to_server +from .coordinator import ( + ImapPollingDataUpdateCoordinator, + ImapPushDataUpdateCoordinator, + connect_to_server, +) from .errors import InvalidAuth, InvalidFolder PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -32,7 +36,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (asyncio.TimeoutError, AioImapException) as err: raise ConfigEntryNotReady from err - coordinator = ImapDataUpdateCoordinator(hass, imap_client) + coordinator_class: type[ + ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator + ] + if imap_client.has_capability("IDLE"): + coordinator_class = ImapPushDataUpdateCoordinator + else: + coordinator_class = ImapPollingDataUpdateCoordinator + + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( + coordinator_class(hass, imap_client) + ) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator @@ -49,6 +63,10 @@ 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): - coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = hass.data[ + DOMAIN + ].pop( + entry.entry_id + ) await coordinator.shutdown() return unload_ok diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index e170f79e7f49..e9bbb623013e 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -10,9 +10,10 @@ from typing import Any from aioimaplib import AUTH, IMAP4_SSL, SELECTED, AioImapException import async_timeout -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_CHARSET, CONF_FOLDER, CONF_SEARCH, CONF_SERVER, DOMAIN @@ -20,6 +21,8 @@ from .errors import InvalidAuth, InvalidFolder _LOGGER = logging.getLogger(__name__) +BACKOFF_TIME = 10 + async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" @@ -27,82 +30,179 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: await client.wait_hello_from_server() await client.login(data[CONF_USERNAME], data[CONF_PASSWORD]) if client.protocol.state != AUTH: - raise InvalidAuth + raise InvalidAuth("Invalid username or password") await client.select(data[CONF_FOLDER]) if client.protocol.state != SELECTED: - raise InvalidFolder + raise InvalidFolder(f"Folder {data[CONF_FOLDER]} is invalid") return client -class ImapDataUpdateCoordinator(DataUpdateCoordinator[int]): - """Class for imap client.""" +class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): + """Base class for imap client.""" config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + def __init__( + self, + hass: HomeAssistant, + imap_client: IMAP4_SSL, + update_interval: timedelta | None, + ) -> None: """Initiate imap client.""" - self.hass = hass self.imap_client = imap_client - self.support_push = imap_client.has_capability("IDLE") super().__init__( hass, _LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=10) if not self.support_push else None, + update_interval=update_interval, ) - async def _async_update_data(self) -> int: - """Update the number of unread emails.""" - try: - if self.imap_client is None: - self.imap_client = await connect_to_server(self.config_entry.data) - except (AioImapException, asyncio.TimeoutError) as err: - raise UpdateFailed(err) from err + async def async_start(self) -> None: + """Start coordinator.""" - return await self.refresh_email_count() - - async def refresh_email_count(self) -> int: - """Check the number of found emails.""" - try: - await self.imap_client.noop() - result, lines = await self.imap_client.search( - self.config_entry.data[CONF_SEARCH], - charset=self.config_entry.data[CONF_CHARSET], - ) - except (AioImapException, asyncio.TimeoutError) as err: - raise UpdateFailed(err) from err + async def _async_reconnect_if_needed(self) -> None: + """Connect to imap server.""" + if self.imap_client is None: + self.imap_client = await connect_to_server(self.config_entry.data) + async def _async_fetch_number_of_messages(self) -> int | None: + """Fetch number of messages.""" + await self._async_reconnect_if_needed() + await self.imap_client.noop() + result, lines = await self.imap_client.search( + self.config_entry.data[CONF_SEARCH], + charset=self.config_entry.data[CONF_CHARSET], + ) if result != "OK": raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - if self.support_push: - self.hass.async_create_background_task( - self.async_wait_server_push(), "Wait for IMAP data push" - ) return len(lines[0].split()) - async def async_wait_server_push(self) -> None: - """Wait for data push from server.""" - try: - idle: asyncio.Future = await self.imap_client.idle_start() - await self.imap_client.wait_server_push() - self.imap_client.idle_done() - async with async_timeout.timeout(10): - await idle - - except (AioImapException, asyncio.TimeoutError): - _LOGGER.warning( - "Lost %s (will attempt to reconnect)", - self.config_entry.data[CONF_SERVER], - ) + async def _cleanup(self, log_error: bool = False) -> None: + """Close resources.""" + if self.imap_client: + try: + if self.imap_client.has_pending_idle(): + self.imap_client.idle_done() + await self.imap_client.stop_wait_server_push() + await self.imap_client.close() + await self.imap_client.logout() + except (AioImapException, asyncio.TimeoutError) as ex: + if log_error: + self.async_set_update_error(ex) + _LOGGER.warning("Error while cleaning up imap connection") self.imap_client = None - await self.async_request_refresh() async def shutdown(self, *_) -> None: """Close resources.""" - if self.imap_client: - if self.imap_client.has_pending_idle(): + await self._cleanup(log_error=True) + + +class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): + """Class for imap client.""" + + def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + """Initiate imap client.""" + super().__init__(hass, imap_client, timedelta(seconds=10)) + + async def _async_update_data(self) -> int | None: + """Update the number of unread emails.""" + try: + return await self._async_fetch_number_of_messages() + except ( + AioImapException, + UpdateFailed, + asyncio.TimeoutError, + ) as ex: + self.async_set_update_error(ex) + await self._cleanup() + raise UpdateFailed() from ex + except InvalidFolder as ex: + _LOGGER.warning("Selected mailbox folder is invalid") + self.async_set_update_error(ex) + await self._cleanup() + raise ConfigEntryError("Selected mailbox folder is invalid.") from ex + except InvalidAuth as ex: + _LOGGER.warning("Username or password incorrect, starting reauthentication") + self.async_set_update_error(ex) + await self._cleanup() + raise ConfigEntryAuthFailed() from ex + + +class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): + """Class for imap client.""" + + def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + """Initiate imap client.""" + super().__init__(hass, imap_client, None) + self._push_wait_task: asyncio.Task[None] | None = None + + async def _async_update_data(self) -> int | None: + """Update the number of unread emails.""" + await self.async_start() + return None + + async def async_start(self) -> None: + """Start coordinator.""" + self._push_wait_task = self.hass.async_create_background_task( + self._async_wait_push_loop(), "Wait for IMAP data push" + ) + + async def _async_wait_push_loop(self) -> None: + """Wait for data push from server.""" + while True: + try: + number_of_messages = await self._async_fetch_number_of_messages() + except InvalidAuth as ex: + _LOGGER.warning( + "Username or password incorrect, starting reauthentication" + ) + self.config_entry.async_start_reauth(self.hass) + self.async_set_update_error(ex) + await self._cleanup() + await asyncio.sleep(BACKOFF_TIME) + except InvalidFolder as ex: + _LOGGER.warning("Selected mailbox folder is invalid") + self.config_entry.async_set_state( + self.hass, + ConfigEntryState.SETUP_ERROR, + "Selected mailbox folder is invalid.", + ) + self.async_set_update_error(ex) + await self._cleanup() + await asyncio.sleep(BACKOFF_TIME) + except ( + UpdateFailed, + AioImapException, + asyncio.TimeoutError, + ) as ex: + self.async_set_update_error(ex) + await self._cleanup() + await asyncio.sleep(BACKOFF_TIME) + continue + else: + self.async_set_updated_data(number_of_messages) + try: + idle: asyncio.Future = await self.imap_client.idle_start() + await self.imap_client.wait_server_push() self.imap_client.idle_done() - await self.imap_client.stop_wait_server_push() - await self.imap_client.logout() + async with async_timeout.timeout(10): + await idle + + except (AioImapException, asyncio.TimeoutError): + _LOGGER.warning( + "Lost %s (will attempt to reconnect after %s s)", + self.config_entry.data[CONF_SERVER], + BACKOFF_TIME, + ) + self.async_set_update_error(UpdateFailed("Lost connection")) + await self._cleanup() + await asyncio.sleep(BACKOFF_TIME) + continue + + async def shutdown(self, *_) -> None: + """Close resources.""" + if self._push_wait_task: + self._push_wait_task.cancel() + await super().shutdown() diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 20457209e994..0bccce0c98dc 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ImapDataUpdateCoordinator +from . import ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator from .const import ( CONF_CHARSET, CONF_FOLDER, @@ -69,18 +69,26 @@ async def async_setup_entry( ) -> None: """Set up the Imap sensor.""" - coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( + hass.data[DOMAIN][entry.entry_id] + ) async_add_entities([ImapSensor(coordinator)]) -class ImapSensor(CoordinatorEntity[ImapDataUpdateCoordinator], SensorEntity): +class ImapSensor( + CoordinatorEntity[ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator], + SensorEntity, +): """Representation of an IMAP sensor.""" _attr_icon = "mdi:email-outline" _attr_has_entity_name = True - def __init__(self, coordinator: ImapDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) # To be removed when YAML import is removed @@ -95,11 +103,6 @@ class ImapSensor(CoordinatorEntity[ImapDataUpdateCoordinator], SensorEntity): ) @property - def native_value(self) -> int: + def native_value(self) -> int | None: """Return the number of emails found.""" return self.coordinator.data - - async def async_update(self) -> None: - """Check for idle state before updating.""" - if not await self.coordinator.imap_client.stop_wait_server_push(): - await super().async_update() From dbebf8c78356ecc13b2ba175c45885b6c3de21d3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:24:33 +0100 Subject: [PATCH 0580/1058] Add state attribute translations for media players (#89821) * Add state attribute translations for media players * Process review comments * Process review comments * Fix and extend * Add yes/no as generic state --- .../components/media_player/strings.json | 118 ++++++++++++++++++ homeassistant/strings.json | 2 + 2 files changed, 120 insertions(+) diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 8627a31307f0..cee0ee200fe9 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -30,6 +30,124 @@ "idle": "[%key:common::state::idle%]", "standby": "[%key:common::state::standby%]", "buffering": "Buffering" + }, + "state_attributes": { + "app_id": { + "name": "App ID" + }, + "app_name": { + "name": "App" + }, + "entity_picture_local": { + "name": "Local accessible entity picture" + }, + "groups_members": { + "name": "Group members" + }, + "is_volume_muted": { + "name": "Muted", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "media_album_artist": { + "name": "Album artist" + }, + "media_album_name": { + "name": "Album" + }, + "media_artist": { + "name": "Artist" + }, + "media_channel": { + "name": "Channel" + }, + "media_content_id": { + "name": "Content ID" + }, + "media_content_type": { + "name": "Content type", + "state": { + "album": "Album", + "app": "App", + "artist": "Artist", + "channel": "Channel", + "channels": "Channels", + "composer": "Composer", + "contributing_artist": "Contributing artist", + "episode": "Episode", + "game": "Game", + "genre": "Genre", + "image": "Image", + "movie": "Movie", + "music": "Music", + "playlist": "Playlist", + "podcast": "Podcast", + "season": "Season", + "track": "Track", + "tvshow": "TV show", + "url": "URL", + "video": "Video" + } + }, + "media_duration": { + "name": "Duration" + }, + "media_episode": { + "name": "Episode" + }, + "media_playlist": { + "name": "Playlist" + }, + "media_position": { + "name": "Position" + }, + "media_position_updated_at": { + "name": "Position updated" + }, + "media_title": { + "name": "Title" + }, + "media_track": { + "name": "Track" + }, + "media_season": { + "name": "Season" + }, + "media_series_title": { + "name": "Series" + }, + "repeat": { + "name": "Repeat", + "state": { + "all": "All", + "off": "Off", + "one": "One" + } + }, + "shuffle": { + "name": "Shuffle", + "state": { + "true": "[%key:common::state::on%]", + "false": "[%key:common::state::off%]" + } + }, + "source": { + "name": "Source" + }, + "source_list": { + "name": "Available sources" + }, + "sound_mode": { + "name": "Sound mode" + }, + "sound_mode_list": { + "name": "Available sound modes" + }, + "volume_level": { + "name": "Volume" + } } }, "tv": { diff --git a/homeassistant/strings.json b/homeassistant/strings.json index c00b51ed6cf6..f3829cd2bd9e 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -3,6 +3,8 @@ "state": { "off": "Off", "on": "On", + "yes": "Yes", + "no": "No", "open": "Open", "closed": "Closed", "connected": "Connected", From 138bbd9c28d93f458c14af1bf77023e1c12b149a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 14:25:29 -1000 Subject: [PATCH 0581/1058] Use json_loads_object util in backup (#89895) * Use json_loads_object util in backup * adjust test --- homeassistant/components/backup/manager.py | 11 ++++++----- tests/components/backup/test_manager.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index e38312dd6ebb..71098f6191fb 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,7 +9,7 @@ from pathlib import Path import tarfile from tarfile import TarError from tempfile import TemporaryDirectory -from typing import Any, Protocol +from typing import Any, Protocol, cast from securetar import SecureTarFile, atomic_contents_add @@ -19,6 +19,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform from homeassistant.helpers.json import save_json from homeassistant.util import dt +from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER @@ -100,11 +101,11 @@ class BackupManager: try: with tarfile.open(backup_path, "r:") as backup_file: if data_file := backup_file.extractfile("./backup.json"): - data = json.loads(data_file.read()) + data = json_loads_object(data_file.read()) backup = Backup( - slug=data["slug"], - name=data["name"], - date=data["date"], + slug=cast(str, data["slug"]), + name=cast(str, data["name"]), + date=cast(str, data["date"]), path=backup_path, size=round(backup_path.stat().st_size / 1_048_576, 2), ) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index aa7d6bae8939..9d3b9889cd3a 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -85,7 +85,7 @@ async def test_load_backups(hass: HomeAssistant) -> None: with patch("pathlib.Path.glob", return_value=[TEST_BACKUP.path]), patch( "tarfile.open", return_value=MagicMock() ), patch( - "json.loads", + "homeassistant.components.backup.manager.json_loads_object", return_value={ "slug": TEST_BACKUP.slug, "name": TEST_BACKUP.name, From b1f64de6cecb99c7a62f816451433b1115cf661c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 14:27:33 -1000 Subject: [PATCH 0582/1058] Remove the old ix_states_event_id index if its no longer being used (#89901) * Remove the old ix_states_event_id index if its no longer being used * cover it * fixes * fixup * Update homeassistant/components/recorder/tasks.py --- homeassistant/components/recorder/const.py | 2 + homeassistant/components/recorder/core.py | 18 +++++++ .../components/recorder/db_schema.py | 5 +- .../components/recorder/migration.py | 51 ++++++++++++++----- homeassistant/components/recorder/queries.py | 7 +++ homeassistant/components/recorder/tasks.py | 14 +++++ homeassistant/components/recorder/util.py | 21 +++++++- .../components/recorder/test_v32_migration.py | 28 ++++++++-- 8 files changed, 124 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index d72666f76b24..6bf46efd3608 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -48,6 +48,8 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 EVENT_TYPE_IDS_SCHEMA_VERSION = 37 STATES_META_SCHEMA_VERSION = 38 +LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 + class SupportedDialect(StrEnum): """Supported dialects.""" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8d1e87086576..538d07eb4d76 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -46,6 +46,7 @@ from .const import ( DOMAIN, EVENT_TYPE_IDS_SCHEMA_VERSION, KEEPALIVE_TIME, + LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION, MARIADB_PYMYSQL_URL_PREFIX, MARIADB_URL_PREFIX, MAX_QUEUE_BACKLOG, @@ -57,7 +58,9 @@ from .const import ( SupportedDialect, ) from .db_schema import ( + LEGACY_STATES_EVENT_ID_INDEX, SCHEMA_VERSION, + TABLE_STATES, Base, EventData, Events, @@ -93,6 +96,7 @@ from .tasks import ( CompileMissingStatisticsTask, DatabaseLockTask, EntityIDMigrationTask, + EventIdMigrationTask, EventsContextIDMigrationTask, EventTask, EventTypeIDMigrationTask, @@ -113,6 +117,7 @@ from .util import ( dburl_to_path, end_incomplete_runs, execute_stmt_lambda_element, + get_index_by_name, is_second_sunday, move_away_broken_database, session_scope, @@ -730,6 +735,15 @@ class Recorder(threading.Thread): _LOGGER.debug("Activating states_meta manager as all data is migrated") self.states_meta_manager.active = True + if self.schema_version > LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION: + with contextlib.suppress(SQLAlchemyError): + # If the index of event_ids on the states table is still present + # we need to queue a task to remove it. + if get_index_by_name( + session, TABLE_STATES, LEGACY_STATES_EVENT_ID_INDEX + ): + self.queue_task(EventIdMigrationTask()) + # We must only set the db ready after we have set the table managers # to active if there is no data to migrate. # @@ -1138,6 +1152,10 @@ class Recorder(threading.Thread): """Post migrate entity_ids if needed.""" return migration.post_migrate_entity_ids(self) + def _cleanup_legacy_states_event_ids(self) -> bool: + """Cleanup legacy event_ids if needed.""" + return migration.cleanup_legacy_states_event_ids(self) + def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 2fa3746a2c88..4826453e4c8b 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -116,6 +116,7 @@ LAST_UPDATED_INDEX_TS = "ix_states_last_updated_ts" METADATA_ID_LAST_UPDATED_INDEX_TS = "ix_states_metadata_id_last_updated_ts" EVENTS_CONTEXT_ID_BIN_INDEX = "ix_events_context_id_bin" STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" +LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id" CONTEXT_ID_BIN_MAX_LENGTH = 16 _DEFAULT_TABLE_ARGS = { @@ -385,9 +386,7 @@ class States(Base): attributes: Mapped[str | None] = mapped_column( Text().with_variant(mysql.LONGTEXT, "mysql", "mariadb") ) # no longer used for new rows - event_id: Mapped[int | None] = mapped_column( # no longer used for new rows - Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True - ) + event_id: Mapped[int | None] = mapped_column(Integer) # no longer used for new rows last_changed: Mapped[datetime | None] = mapped_column( DATETIME_TYPE ) # no longer used for new rows diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 08f5f21b896f..4619c4531d0c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -30,6 +30,7 @@ from homeassistant.util.ulid import ulid_to_bytes from .const import SupportedDialect from .db_schema import ( CONTEXT_ID_BIN_MAX_LENGTH, + LEGACY_STATES_EVENT_ID_INDEX, SCHEMA_VERSION, STATISTICS_TABLES, TABLE_STATES, @@ -51,6 +52,7 @@ from .queries import ( find_event_type_to_migrate, find_events_context_ids_to_migrate, find_states_context_ids_to_migrate, + has_used_states_event_ids, ) from .statistics import ( correct_db_schema as statistics_correct_db_schema, @@ -64,7 +66,12 @@ from .tasks import ( PostSchemaMigrationTask, StatisticsTimestampMigrationCleanupTask, ) -from .util import database_job_retry_wrapper, retryable_database_job, session_scope +from .util import ( + database_job_retry_wrapper, + get_index_by_name, + retryable_database_job, + session_scope, +) if TYPE_CHECKING: from . import Recorder @@ -308,18 +315,7 @@ def _drop_index( with session_scope(session=session_maker()) as session, contextlib.suppress( SQLAlchemyError ): - connection = session.connection() - inspector = sqlalchemy.inspect(connection) - indexes = inspector.get_indexes(table_name) - if index_to_drop := next( - ( - possible_index["name"] - for possible_index in indexes - if possible_index["name"] - and possible_index["name"].endswith(f"_{index_name}") - ), - None, - ): + if index_to_drop := get_index_by_name(session, table_name, index_name): connection.execute(text(f"DROP INDEX {index_to_drop}")) success = True @@ -593,7 +589,7 @@ def _apply_update( # noqa: C901 # but it was removed in version 32 elif new_version == 5: # Create supporting index for States.event_id foreign key - _create_index(session_maker, "states", "ix_states_event_id") + _create_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) elif new_version == 6: _add_columns( session_maker, @@ -1529,6 +1525,33 @@ def post_migrate_entity_ids(instance: Recorder) -> bool: return is_done +@retryable_database_job("cleanup_legacy_event_ids") +def cleanup_legacy_states_event_ids(instance: Recorder) -> bool: + """Remove old event_id index from states. + + We used to link states to events using the event_id column but we no + longer store state changed events in the events table. + + If all old states have been purged and existing states are in the new + format we can drop the index since it can take up ~10MB per 1M rows. + """ + session_maker = instance.get_session + _LOGGER.debug("Cleanup legacy entity_ids") + with session_scope(session=session_maker()) as session: + result = session.execute(has_used_states_event_ids()).scalar() + # In the future we may migrate existing states to the new format + # but in practice very few of these still exist in production and + # removing the index is the likely all that needs to happen. + all_gone = not result + + if all_gone: + # Only drop the index if there are no more event_ids in the states table + # ex all NULL + _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) + + return True + + def _initialize_database(session: Session) -> bool: """Initialize a new database. diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 5a2c7040f43f..f983224e212b 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -745,6 +745,13 @@ def batch_cleanup_entity_ids() -> StatementLambdaElement: ) +def has_used_states_event_ids() -> StatementLambdaElement: + """Check if there are used event_ids in the states table.""" + return lambda_stmt( + lambda: select(States.state_id).filter(States.event_id.isnot(None)).limit(1) + ) + + def has_events_context_ids_to_migrate() -> StatementLambdaElement: """Check if there are events context ids to migrate.""" return lambda_stmt( diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 5762a9ab69cd..ef8f6a95a7cb 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -438,3 +438,17 @@ class EntityIDPostMigrationTask(RecorderTask): ): # Schedule a new migration task if this one didn't finish instance.queue_task(EntityIDPostMigrationTask()) + + +@dataclass +class EventIdMigrationTask(RecorderTask): + """An object to insert into the recorder queue to cleanup legacy event_ids in the states table. + + This task should only be queued if the ix_states_event_id index exists + since it is used to scan the states table and it will be removed after this + task is run if its no longer needed. + """ + + def run(self, instance: Recorder) -> None: + """Clean up the legacy event_id index on states.""" + instance._cleanup_legacy_states_event_ids() # pylint: disable=[protected-access] diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index ae09f9fd6a2d..4ec0a0c4501a 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -18,7 +18,7 @@ from awesomeversion import ( AwesomeVersionStrategy, ) import ciso8601 -from sqlalchemy import text +from sqlalchemy import inspect, text from sqlalchemy.engine import Result, Row from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.query import Query @@ -832,3 +832,22 @@ def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: From more-itertools """ return iter(partial(take, chunked_num, iter(iterable)), []) + + +def get_index_by_name(session: Session, table_name: str, index_name: str) -> str | None: + """Get an index by name.""" + connection = session.connection() + inspector = inspect(connection) + indexes = inspector.get_indexes(table_name) + return next( + ( + possible_index["name"] + for possible_index in indexes + if possible_index["name"] + and ( + possible_index["name"] == index_name + or possible_index["name"].endswith(f"_{index_name}") + ) + ), + None, + ) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 22aa96f8e2f1..dd49d7b21e1e 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -91,6 +91,10 @@ async def test_migrate_times( ) number_of_migrations = 5 + def _get_states_index_names(): + with session_scope(hass=hass) as session: + return inspect(session.connection()).get_indexes("states") + with patch.object(recorder, "db_schema", old_db_schema), patch.object( recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object( @@ -113,6 +117,8 @@ async def test_migrate_times( "homeassistant.components.recorder.Recorder._migrate_entity_ids", ), patch( "homeassistant.components.recorder.Recorder._post_migrate_entity_ids" + ), patch( + "homeassistant.components.recorder.Recorder._cleanup_legacy_states_event_ids" ): hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) @@ -132,11 +138,18 @@ async def test_migrate_times( await hass.async_block_till_done() await recorder.get_instance(hass).async_block_till_done() + states_indexes = await recorder.get_instance(hass).async_add_executor_job( + _get_states_index_names + ) + states_index_names = {index["name"] for index in states_indexes} + await hass.async_stop() await hass.async_block_till_done() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + assert "ix_states_event_id" in states_index_names + # Test that the duplicates are removed during migration from schema 23 hass = await async_test_home_assistant(asyncio.get_running_loop()) recorder_helper.async_initialize_recorder(hass) @@ -186,13 +199,20 @@ async def test_migrate_times( with session_scope(hass=hass) as session: return inspect(session.connection()).get_indexes("events") - indexes = await recorder.get_instance(hass).async_add_executor_job( + events_indexes = await recorder.get_instance(hass).async_add_executor_job( _get_events_index_names ) - index_names = {index["name"] for index in indexes} + events_index_names = {index["name"] for index in events_indexes} - assert "ix_events_context_id_bin" in index_names - assert "ix_events_context_id" not in index_names + assert "ix_events_context_id_bin" in events_index_names + assert "ix_events_context_id" not in events_index_names + + states_indexes = await recorder.get_instance(hass).async_add_executor_job( + _get_states_index_names + ) + states_index_names = {index["name"] for index in states_indexes} + + assert "ix_states_event_id" not in states_index_names await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ From e87359761b96969229d309283346da49871f10e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 14:28:29 -1000 Subject: [PATCH 0583/1058] Fix some I/O in the event loop during backup (#89894) --- homeassistant/components/backup/manager.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 71098f6191fb..69df310bd556 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -187,13 +187,8 @@ class BackupManager: "compressed": True, } tar_file_path = Path(self.backup_dir, f"{backup_data['slug']}.tar") - - if not self.backup_dir.exists(): - LOGGER.debug("Creating backup directory") - self.hass.async_add_executor_job(self.backup_dir.mkdir) - - await self.hass.async_add_executor_job( - self._generate_backup_contents, + size_in_bytes = await self.hass.async_add_executor_job( + self._mkdir_and_generate_backup_contents, tar_file_path, backup_data, ) @@ -202,7 +197,7 @@ class BackupManager: name=backup_name, date=date_str, path=tar_file_path, - size=round(tar_file_path.stat().st_size / 1_048_576, 2), + size=round(size_in_bytes / 1_048_576, 2), ) if self.loaded_backups: self.backups[slug] = backup @@ -221,12 +216,16 @@ class BackupManager: if isinstance(result, Exception): raise result - def _generate_backup_contents( + def _mkdir_and_generate_backup_contents( self, tar_file_path: Path, backup_data: dict[str, Any], - ) -> None: - """Generate backup contents.""" + ) -> int: + """Generate backup contents and return the size.""" + if not self.backup_dir.exists(): + LOGGER.debug("Creating backup directory") + self.backup_dir.mkdir() + with TemporaryDirectory() as tmp_dir, SecureTarFile( tar_file_path, "w", gzip=False ) as tar_file: @@ -246,6 +245,7 @@ class BackupManager: arcname="data", ) tar_file.add(tmp_dir_path, arcname=".") + return tar_file_path.stat().st_size def _generate_slug(date: str, name: str) -> str: From 1f4164def837954ef82fa760693a3103099c23c4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:29:12 +0100 Subject: [PATCH 0584/1058] Add state (attribute) translations for Text (#89898) --- homeassistant/components/text/strings.json | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index 0f5ddf5b3318..034f1ab315b8 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -4,5 +4,28 @@ "action_type": { "set_value": "Set value for {entity_name}" } + }, + "entity_component": { + "_": { + "name": "[%key:component::text::title%]", + "state_attributes": { + "max": { + "name": "Max length" + }, + "min": { + "name": "Min length" + }, + "mode": { + "name": "Mode", + "state": { + "text": "Text", + "password": "Password" + } + }, + "pattern": { + "name": "Pattern" + } + } + } } } From 8ecd73cac7dd4666262a1cb0c77637685c58194d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:29:48 +0100 Subject: [PATCH 0585/1058] Add state attribute translations for Weather (#89897) --- homeassistant/components/weather/strings.json | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 0f88f1ae7e2e..a64f84672242 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -1,7 +1,8 @@ { + "title": "Weather", "entity_component": { "_": { - "name": "Weather", + "name": "[%key:component::weather::title%]", "state": { "clear-night": "Clear, night", "cloudy": "Cloudy", @@ -18,6 +19,47 @@ "sunny": "Sunny", "windy": "Windy", "windy-variant": "Windy" + }, + "state_attributes": { + "forecast": { + "name": "Forecast" + }, + "humidity": { + "name": "Humidity" + }, + "ozone": { + "name": "Ozone" + }, + "precipitation_unit": { + "name": "Precipitation unit" + }, + "pressure": { + "name": "Pressure" + }, + "pressure_unit": { + "name": "Pressure unit" + }, + "temperature": { + "name": "Temperature" + }, + "temperature_unit": { + "name": "Temperature unit" + }, + "visibility": { + "name": "Visibility" + }, + "visibility_unit": { + "name": "Visibility unit" + }, + "wind_bearing": { + "name": "Wind bearing" + }, + "wind_speed": { + "name": "Wind speed" + }, + "wind_speed_unit": { + "name": "Wind speed unit" + } } } } From 30e7ab247d816269b18825e8ae3d9a76f420f787 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Mar 2023 14:32:24 -1000 Subject: [PATCH 0586/1058] Small cleanups to writing entity state (#89890) * Small cleanups to writing entity state * reduce one prop access * small cleanups * small cleanups * name conflict --- homeassistant/helpers/entity.py | 62 +++++++++++++++++---------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 63b70aa13d93..9d9e685d6a83 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -579,6 +579,25 @@ class Entity(ABC): return f"{state:.{FLOAT_PRECISION}}" return str(state) + def _friendly_name_internal(self) -> str | None: + """Return the friendly name. + + If has_entity_name is False, this returns self.name + If has_entity_name is True, this returns device.name + self.name + """ + if not self.has_entity_name or not self.registry_entry: + return self.name + + device_registry = dr.async_get(self.hass) + if not (device_id := self.registry_entry.device_id) or not ( + device_entry := device_registry.async_get(device_id) + ): + return self.name + + if not (name := self.name): + return device_entry.name_by_user or device_entry.name + return f"{device_entry.name_by_user or device_entry.name} {name}" + @callback def _async_write_ha_state(self) -> None: """Write the state to the state machine.""" @@ -586,7 +605,11 @@ class Entity(ABC): # Polling returned after the entity has already been removed return - if self.registry_entry and self.registry_entry.disabled_by: + hass = self.hass + entity_id = self.entity_id + entry = self.registry_entry + + if entry and entry.disabled_by: if not self._disabled_reported: self._disabled_reported = True assert self.platform is not None @@ -595,7 +618,7 @@ class Entity(ABC): "Entity %s is incorrectly being triggered for updates while it" " is disabled. This is a bug in the %s integration" ), - self.entity_id, + entity_id, self.platform.platform_name, ) return @@ -614,8 +637,6 @@ class Entity(ABC): if (unit_of_measurement := self.unit_of_measurement) is not None: attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement - entry = self.registry_entry - if assumed_state := self.assumed_state: attr[ATTR_ASSUMED_STATE] = assumed_state @@ -633,26 +654,9 @@ class Entity(ABC): if (icon := (entry and entry.icon) or self.icon) is not None: attr[ATTR_ICON] = icon - def friendly_name() -> str | None: - """Return the friendly name. - - If has_entity_name is False, this returns self.name - If has_entity_name is True, this returns device.name + self.name - """ - if not self.has_entity_name or not self.registry_entry: - return self.name - - device_registry = dr.async_get(self.hass) - if not (device_id := self.registry_entry.device_id) or not ( - device_entry := device_registry.async_get(device_id) - ): - return self.name - - if not self.name: - return device_entry.name_by_user or device_entry.name - return f"{device_entry.name_by_user or device_entry.name} {self.name}" - - if (name := (entry and entry.name) or friendly_name()) is not None: + if ( + name := (entry and entry.name) or self._friendly_name_internal() + ) is not None: attr[ATTR_FRIENDLY_NAME] = name if (supported_features := self.supported_features) is not None: @@ -665,15 +669,15 @@ class Entity(ABC): report_issue = self._suggest_report_issue() _LOGGER.warning( "Updating state for %s (%s) took %.3f seconds. Please %s", - self.entity_id, + entity_id, type(self), end - start, report_issue, ) # Overwrite properties that have been set in the config file. - if DATA_CUSTOMIZE in self.hass.data: - attr.update(self.hass.data[DATA_CUSTOMIZE].get(self.entity_id)) + if customize := hass.data.get(DATA_CUSTOMIZE): + attr.update(customize.get(entity_id)) if ( self._context_set is not None @@ -682,9 +686,7 @@ class Entity(ABC): self._context = None self._context_set = None - self.hass.states.async_set( - self.entity_id, state, attr, self.force_update, self._context - ) + hass.states.async_set(entity_id, state, attr, self.force_update, self._context) def schedule_update_ha_state(self, force_refresh: bool = False) -> None: """Schedule an update ha state change task. From cd3819abec84ad95c95dabd8ca3c5c7277dbe0eb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:32:52 +0100 Subject: [PATCH 0587/1058] Add state attribute translations for Sensor (#89896) --- homeassistant/components/sensor/strings.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index a579be672054..5b34c5a28e32 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -100,6 +100,22 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "last_reset": { + "name": "Last reset" + }, + "options": { + "name": "Possible states" + }, + "state_class": { + "name": "State class", + "state": { + "measurement": "Measurement", + "total": "Total", + "total_increasing": "Total increasing" + } + } } }, "date": { From d106cb48d2cebfc41883e9faf51b2769b8b5dffb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 18 Mar 2023 01:35:25 +0100 Subject: [PATCH 0588/1058] Add state attribute translations for light (#89818) * Add state attribute translations for light * Process review comments --- homeassistant/components/light/strings.json | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index ef3fa74bd98c..935e38d33d96 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -25,6 +25,65 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "brightness": { + "name": "Brightness" + }, + "color_mode": { + "name": "Color mode", + "state": { + "brightness": "Brightness only", + "color_temp": "Color temperature", + "hs": "HS", + "onoff": "On/Off", + "rgb": "RGB", + "rgbw": "RGBW", + "rgbww": "RGBWW", + "unknown": "Unknown", + "white": "White", + "xy": "XY" + } + }, + "color_temp": { + "name": "Color temperature (mireds)" + }, + "color_temp_kelvin": { + "name": "Color temperature (Kelvin)" + }, + "effect": { + "name": "Effect" + }, + "effect_list": { + "name": "Available effects" + }, + "max_color_temp_kelvin": { + "name": "Maximum color temperature (Kelvin)" + }, + "min_color_temp_kelvin": { + "name": "Minimum color temperature (Kelvin)" + }, + "max_mireds": { + "name": "Maximum color temperature (mireds)" + }, + "min_mireds": { + "name": "Minimum color temperature (mireds)" + }, + "supported_color_modes": { + "name": "Available color modes", + "state": { + "brightness": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::brightness%]", + "color_temp": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::color_temp%]", + "hs": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::hs%]", + "onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]", + "rgb": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgb%]", + "rgbw": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbw%]", + "rgbww": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::rgbww%]", + "unknown": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::unknown%]", + "white": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::white%]", + "xy": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::xy%]" + } + } } } } From 6ad9f420ab7e0e85146853fe9fd8ef5c12421ccb Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Sat, 18 Mar 2023 20:50:50 +0100 Subject: [PATCH 0589/1058] Add Landis+Gyr poll on restart (#89644) --- .../landisgyr_heat_meter/__init__.py | 1 + .../components/landisgyr_heat_meter/sensor.py | 133 +++++++++++------- .../landisgyr_heat_meter/test_sensor.py | 120 +--------------- 3 files changed, 87 insertions(+), 167 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 541fef017d01..3a44267bd41f 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 508ae43b8e3b..af9662974212 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -1,14 +1,16 @@ """Platform for sensor integration.""" from __future__ import annotations -from dataclasses import asdict +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime import logging from ultraheat_api.response import HeatMeterResponse from homeassistant.components.sensor import ( - RestoreSensor, SensorDeviceClass, + SensorEntity, SensorEntityDescription, SensorStateClass, ) @@ -25,6 +27,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -36,177 +39,220 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) +@dataclass +class HeatMeterSensorEntityDescriptionMixin: + """Mixin for additional Heat Meter sensor description attributes .""" + + value_fn: Callable[[HeatMeterResponse], StateType | datetime] + + +@dataclass +class HeatMeterSensorEntityDescription( + SensorEntityDescription, HeatMeterSensorEntityDescriptionMixin +): + """Heat Meter sensor description.""" + + HEAT_METER_SENSOR_TYPES = ( - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="volume_usage_m3", icon="mdi:fire", name="Volume usage", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL, + value_fn=lambda res: getattr(res, "volume_usage_m3", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="heat_usage_gj", icon="mdi:fire", name="Heat usage GJ", native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, + value_fn=lambda res: getattr(res, "heat_usage_gj", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="heat_previous_year_gj", icon="mdi:fire", name="Heat previous year GJ", - native_unit_of_measurement="GJ", + native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "heat_previous_year_gj", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="volume_previous_year_m3", icon="mdi:fire", name="Volume usage previous year", device_class=SensorDeviceClass.VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "volume_previous_year_m3", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="ownership_number", name="Ownership number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "ownership_number", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="error_number", name="Error number", icon="mdi:home-alert", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "error_number", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="device_number", name="Device number", icon="mdi:identifier", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "device_number", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="measurement_period_minutes", name="Measurement period minutes", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "measurement_period_minutes", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="power_max_kw", name="Power max", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "power_max_kw", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="power_max_previous_year_kw", name="Power max previous year", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "power_max_previous_year_kw", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flowrate_max_m3ph", name="Flowrate max", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flowrate_max_m3ph", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flowrate_max_previous_year_m3ph", name="Flowrate max previous year", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flowrate_max_previous_year_m3ph", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="return_temperature_max_c", name="Return temperature max", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "return_temperature_max_c", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="return_temperature_max_previous_year_c", name="Return temperature max previous year", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr( + res, "return_temperature_max_previous_year_c", None + ), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flow_temperature_max_c", name="Flow temperature max", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flow_temperature_max_c", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flow_temperature_max_previous_year_c", name="Flow temperature max previous year", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flow_temperature_max_previous_year_c", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="operating_hours", name="Operating hours", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "operating_hours", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="flow_hours", name="Flow hours", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "flow_hours", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="fault_hours", name="Fault hours", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "fault_hours", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="fault_hours_previous_year", name="Fault hours previous year", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "fault_hours_previous_year", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="yearly_set_day", name="Yearly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "yearly_set_day", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="monthly_set_day", name="Monthly set day", icon="mdi:clock-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "monthly_set_day", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="meter_date_time", name="Meter date time", icon="mdi:clock-outline", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: dt_util.as_utc(res.meter_date_time) + if res.meter_date_time + else None, ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="measuring_range_m3ph", name="Measuring range", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, icon="mdi:water-outline", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "measuring_range_m3ph", None), ), - SensorEntityDescription( + HeatMeterSensorEntityDescription( key="settings_and_firmware", name="Settings and firmware", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda res: getattr(res, "settings_and_firmware", None), ), ) @@ -238,14 +284,17 @@ async def async_setup_entry( class HeatMeterSensor( - CoordinatorEntity[DataUpdateCoordinator[HeatMeterResponse]], RestoreSensor + CoordinatorEntity[DataUpdateCoordinator[HeatMeterResponse]], + SensorEntity, ): """Representation of a Sensor.""" + entity_description: HeatMeterSensorEntityDescription + def __init__( self, coordinator: DataUpdateCoordinator[HeatMeterResponse], - description: SensorEntityDescription, + description: HeatMeterSensorEntityDescription, device: DeviceInfo, ) -> None: """Set up the sensor with the initial values.""" @@ -254,25 +303,9 @@ class HeatMeterSensor( self._attr_unique_id = f"{coordinator.config_entry.data['device_number']}_{description.key}" # type: ignore[union-attr] self._attr_name = f"Heat Meter {description.name}" self.entity_description = description - self._attr_device_info = device - self._attr_should_poll = bool(self.key in ("heat_usage", "heat_previous_year")) - async def async_added_to_hass(self) -> None: - """Call when entity about to be added to hass.""" - await super().async_added_to_hass() - state = await self.async_get_last_sensor_data() - if state: - self._attr_native_value = state.native_value - - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - if self.key in asdict(self.coordinator.data): - if self.device_class == SensorDeviceClass.TIMESTAMP: - self._attr_native_value = dt_util.as_utc( - asdict(self.coordinator.data)[self.key] - ) - else: - self._attr_native_value = asdict(self.coordinator.data)[self.key] - - self.async_write_ha_state() + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 6296fadd1163..a37fab65a10f 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -5,20 +5,15 @@ from unittest.mock import patch import serial -from homeassistant.components.homeassistant import ( - DOMAIN as HA_DOMAIN, - SERVICE_UPDATE_ENTITY, -) +from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.landisgyr_heat_meter.const import DOMAIN, POLLING_INTERVAL from homeassistant.components.sensor import ( - ATTR_LAST_RESET, ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, @@ -26,16 +21,12 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfVolume, ) -from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - mock_restore_cache_with_extra_data, -) +from tests.common import MockConfigEntry, async_fire_time_changed API_HEAT_METER_SERVICE = ( "homeassistant.components.landisgyr_heat_meter.ultraheat_api.HeatMeterService" @@ -80,13 +71,6 @@ async def test_create_sensors( await hass.config_entries.async_setup(mock_entry.entry_id) await async_setup_component(hass, HA_DOMAIN, {}) await hass.async_block_till_done() - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage_gj"}, - blocking=True, - ) - await hass.async_block_till_done() # check if 26 attributes have been created assert len(hass.states.async_all()) == 25 @@ -121,97 +105,6 @@ async def test_create_sensors( assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC -@patch(API_HEAT_METER_SERVICE) -async def test_restore_state(mock_heat_meter, hass: HomeAssistant) -> None: - """Test sensor restore state.""" - # Home assistant is not running yet - hass.state = CoreState.not_running - last_reset = "2022-07-01T00:00:00.000000+00:00" - mock_restore_cache_with_extra_data( - hass, - [ - ( - State( - "sensor.heat_meter_heat_usage_gj", - "34167", - attributes={ - ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.GIGA_JOULE, - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - }, - ), - { - "native_value": 34167, - "native_unit_of_measurement": UnitOfEnergy.GIGA_JOULE, - "icon": "mdi:fire", - "last_reset": last_reset, - }, - ), - ( - State( - "sensor.heat_meter_volume_usage", - "456", - attributes={ - ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfVolume.CUBIC_METERS, - ATTR_STATE_CLASS: SensorStateClass.TOTAL, - }, - ), - { - "native_value": 456, - "native_unit_of_measurement": UnitOfVolume.CUBIC_METERS, - "icon": "mdi:fire", - "last_reset": last_reset, - }, - ), - ( - State( - "sensor.heat_meter_device_number", - "devicenr_789", - attributes={ - ATTR_LAST_RESET: last_reset, - }, - ), - { - "native_value": "devicenr_789", - "native_unit_of_measurement": None, - "last_reset": last_reset, - }, - ), - ], - ) - entry_data = { - "device": "/dev/USB0", - "model": "LUGCUH50", - "device_number": "123456789", - } - - # create and add entry - mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) - mock_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - - # restore from cache - state = hass.states.get("sensor.heat_meter_heat_usage_gj") - assert state - assert state.state == "34167" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.GIGA_JOULE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - - state = hass.states.get("sensor.heat_meter_volume_usage") - assert state - assert state.state == "456" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - - state = hass.states.get("sensor.heat_meter_device_number") - assert state - assert state.state == "devicenr_789" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - @patch(API_HEAT_METER_SERVICE) async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> None: """Test sensor.""" @@ -237,13 +130,6 @@ async def test_exception_on_polling(mock_heat_meter, hass: HomeAssistant) -> Non await hass.config_entries.async_setup(mock_entry.entry_id) await async_setup_component(hass, HA_DOMAIN, {}) await hass.async_block_till_done() - await hass.services.async_call( - HA_DOMAIN, - SERVICE_UPDATE_ENTITY, - {ATTR_ENTITY_ID: "sensor.heat_meter_heat_usage_gj"}, - blocking=True, - ) - await hass.async_block_till_done() # check if initial setup succeeded state = hass.states.get("sensor.heat_meter_heat_usage_gj") From e937693d975f1afe1bb30bd9fd348e57bff25518 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 19 Mar 2023 01:57:40 +0100 Subject: [PATCH 0590/1058] Fix blocking MQTT entry unload (#89922) * Remove unneeded async_block_till_done * use await asyncio.sleep(0) instead --- homeassistant/components/mqtt/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index c73eec12449e..5a9eb7c3fcb2 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -704,7 +704,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for component in PLATFORMS ) ) - await hass.async_block_till_done() + await asyncio.sleep(0) # Unsubscribe reload dispatchers while reload_dispatchers := mqtt_data.reload_dispatchers: reload_dispatchers.pop()() From 95240e8aad5536fa6bad1f9de1bd938b7e3fd178 Mon Sep 17 00:00:00 2001 From: Jesse Moody Date: Sun, 19 Mar 2023 02:52:42 -0400 Subject: [PATCH 0591/1058] Change README demo to demo.home-assistant.io subdomain (#89921) change home assistant demo link --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 6f5e0e69892b..084949dc44e7 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ Home Assistant |Chat Status| Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts. Perfect to run on a Raspberry Pi or a local server. Check out `home-assistant.io `__ for `a -demo `__, `installation instructions `__, +demo `__, `installation instructions `__, `tutorials `__ and `documentation `__. |screenshot-states| @@ -23,6 +23,6 @@ of a component, check the `Home Assistant help section Date: Sat, 18 Mar 2023 20:59:05 -1000 Subject: [PATCH 0592/1058] Remove async_block_till_done in freebox (#89928) async_block_till_done() is not meant to be called in integrations --- homeassistant/components/freebox/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index fd9252aaa173..dbee01c4e7d5 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -77,7 +77,6 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check permissions await fbx.system.get_config() await fbx.lan.get_hosts_list() - await self.hass.async_block_till_done() # Close connection await fbx.close() From 36ad2c81f10e6d0efb4f7b64e82df87ea57172a3 Mon Sep 17 00:00:00 2001 From: Sven Serlier <85389871+wrt54g@users.noreply.github.com> Date: Sun, 19 Mar 2023 08:06:44 +0100 Subject: [PATCH 0593/1058] Adjust "Lovelace" to "Dashboards" (#89927) "Lovelace" to "Dashboards" --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2440cb7ff29a..8a4c7d467088 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,6 +1,6 @@ blank_issues_enabled: false contact_links: - - name: Report a bug with the UI, Frontend or Lovelace + - name: Report a bug with the UI, Frontend or Dashboards url: https://github.com/home-assistant/frontend/issues about: This is the issue tracker for our backend. Please report issues with the UI in the frontend repository. - name: Report incorrect or missing information on our website From 0e7bd401f28b15c649090ff5244c115318c526f7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 19 Mar 2023 08:56:24 +0100 Subject: [PATCH 0594/1058] Fix lingering timer in config entry flow tests (#89853) --- tests/helpers/test_config_entry_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 90d8030be79a..23f6571e8bc0 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -355,6 +355,7 @@ async def test_webhook_config_flow_registers_webhook( assert result["data"]["webhook_id"] is not None +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_webhook_create_cloudhook( hass: HomeAssistant, webhook_flow_conf: None ) -> None: @@ -410,6 +411,7 @@ async def test_webhook_create_cloudhook( assert result["require_restart"] is False +@patch("homeassistant.components.cloud.STARTUP_REPAIR_DELAY", 0) async def test_webhook_create_cloudhook_aborts_not_connected( hass: HomeAssistant, webhook_flow_conf: None ) -> None: From 87264d219a696093c7cecec0211db365940222fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Mar 2023 23:13:48 -1000 Subject: [PATCH 0595/1058] Fix ssl context being recreated frequently in httpx (#89932) * Fix ssl context being created every time in httpx * its expensive, only do it once --- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/helpers/httpx_client.py | 3 ++- homeassistant/util/ssl.py | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index af8fa4d6f4dd..53c3cc1cf222 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -271,7 +271,7 @@ def _async_get_connector( return cast(aiohttp.BaseConnector, hass.data[key]) if verify_ssl: - ssl_context: bool | SSLContext = ssl_util.client_context() + ssl_context: bool | SSLContext = ssl_util.get_default_context() else: ssl_context = False diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index 2475469a7d10..1e9d2e776c6b 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -11,6 +11,7 @@ from typing_extensions import Self from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass +from homeassistant.util import ssl as ssl_util from .frame import warn_use @@ -65,7 +66,7 @@ def create_async_httpx_client( This method must be run in the event loop. """ client = HassHttpXAsyncClient( - verify=verify_ssl, + verify=ssl_util.get_default_context() if verify_ssl else False, headers={USER_AGENT: SERVER_SOFTWARE}, **kwargs, ) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 71c88ad8446a..9c945ef27596 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -16,6 +16,15 @@ def client_context() -> ssl.SSLContext: return ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=cafile) +# Create this only once and reuse it +_DEFAULT_SSL_CONTEXT = client_context() + + +def get_default_context() -> ssl.SSLContext: + """Return the default SSL context.""" + return _DEFAULT_SSL_CONTEXT + + def server_context_modern() -> ssl.SSLContext: """Return an SSL context following the Mozilla recommendations. From 0441a64c69acd3c3ff48a1cb2e1c9b95d68a7134 Mon Sep 17 00:00:00 2001 From: Oliver <10700296+ol-iver@users.noreply.github.com> Date: Sun, 19 Mar 2023 11:47:01 +0100 Subject: [PATCH 0596/1058] Update media state via telnet in `denonavr` integration (#89788) --- homeassistant/components/denonavr/manifest.json | 2 +- homeassistant/components/denonavr/media_player.py | 13 ++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 2d6a127ff373..660e4c770b0c 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.1"], + "requirements": ["denonavr==0.11.2"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 5e636c5cfae5..eab4c1df3a60 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -249,11 +249,19 @@ class DenonDevice(MediaPlayerEntity): self._telnet_was_healthy: bool | None = None - async def _telnet_callback(self, zone, event, parameter): + async def _telnet_callback(self, zone, event, parameter) -> None: """Process a telnet command callback.""" + # There are multiple checks implemented which reduce unnecessary updates of the ha state machine if zone != self._receiver.zone: return - + # Some updates trigger multiple events like one for artist and one for title for one change + # We skip every event except the last one + if event == "NS" and not parameter.startswith("E4"): + return + if event == "TA" and not parameter.startwith("ANNAME"): + return + if event == "HD" and not parameter.startswith("ALBUM"): + return self.async_write_ha_state() async def async_added_to_hass(self) -> None: @@ -276,7 +284,6 @@ class DenonDevice(MediaPlayerEntity): if ( telnet_is_healthy := receiver.telnet_connected and receiver.telnet_healthy ) and self._telnet_was_healthy: - await receiver.input.async_update_media_state() return # if async_update raises an exception, we don't want to skip the next update diff --git a/requirements_all.txt b/requirements_all.txt index 6e834d05471e..41b2b1452350 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -586,7 +586,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.1 +denonavr==0.11.2 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30bb11b7456e..ef0c26dc8fe8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -466,7 +466,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.1 +denonavr==0.11.2 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 From 557b9c7d5170860198cd9a622d3a98cd6a00a782 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sun, 19 Mar 2023 02:13:52 -1100 Subject: [PATCH 0597/1058] Add KNX interface device with diagnostic entities (#89213) --- homeassistant/components/knx/__init__.py | 23 +-- homeassistant/components/knx/device.py | 51 ++++++ homeassistant/components/knx/sensor.py | 151 +++++++++++++++++- tests/components/knx/conftest.py | 5 +- tests/components/knx/test_binary_sensor.py | 22 +-- tests/components/knx/test_button.py | 5 +- tests/components/knx/test_climate.py | 20 +-- tests/components/knx/test_cover.py | 5 +- tests/components/knx/test_expose.py | 7 - tests/components/knx/test_fan.py | 3 - tests/components/knx/test_interface_device.py | 112 +++++++++++++ tests/components/knx/test_light.py | 1 - tests/components/knx/test_scene.py | 8 +- tests/components/knx/test_select.py | 2 - tests/components/knx/test_sensor.py | 14 +- tests/components/knx/test_switch.py | 2 - tests/components/knx/test_weather.py | 1 - 17 files changed, 347 insertions(+), 85 deletions(-) create mode 100644 homeassistant/components/knx/device.py create mode 100644 tests/components/knx/test_interface_device.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index f58df9dc11e8..60104545deaf 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -69,6 +69,7 @@ from .const import ( KNX_ADDRESS, SUPPORTED_PLATFORMS, ) +from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure from .schema import ( BinarySensorSchema, @@ -254,13 +255,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: knx_module.exposures.append( create_knx_exposure(hass, knx_module.xknx, expose_config) ) - + # always forward sensor for system entities (telegram counter, etc.) + await hass.config_entries.async_forward_entry_setup(entry, Platform.SENSOR) await hass.config_entries.async_forward_entry_setups( entry, [ platform for platform in SUPPORTED_PLATFORMS - if platform in config and platform is not Platform.NOTIFY + if platform in config and platform not in (Platform.SENSOR, Platform.NOTIFY) ], ) @@ -366,10 +368,17 @@ class KNXModule: self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} self.entry = entry - self.init_xknx() + self.xknx = XKNX( + connection_config=self.connection_config(), + rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], + state_updater=self.entry.data[CONF_KNX_STATE_UPDATER], + ) self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb ) + self.interface_device = KNXInterfaceDevice( + hass=hass, entry=entry, xknx=self.xknx + ) self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} self._group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} @@ -382,14 +391,6 @@ class KNXModule: ) self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) - def init_xknx(self) -> None: - """Initialize XKNX object.""" - self.xknx = XKNX( - connection_config=self.connection_config(), - rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], - state_updater=self.entry.data[CONF_KNX_STATE_UPDATER], - ) - async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" await self.xknx.start() diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py new file mode 100644 index 000000000000..452de577ce08 --- /dev/null +++ b/homeassistant/components/knx/device.py @@ -0,0 +1,51 @@ +"""Handle KNX Devices.""" +from __future__ import annotations + +from xknx import XKNX +from xknx.core import XknxConnectionState +from xknx.io.gateway_scanner import GatewayDescriptor + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN + + +class KNXInterfaceDevice: + """Class for KNX Interface Device handling.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, xknx: XKNX) -> None: + """Initialize interface device class.""" + self.device_registry = dr.async_get(hass) + self.gateway_descriptor: GatewayDescriptor | None = None + self.xknx = xknx + + _device_id = (DOMAIN, f"_{entry.entry_id}_interface") + self.device = self.device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + default_name="KNX Interface", + identifiers={_device_id}, + ) + self.device_info = DeviceInfo(identifiers={_device_id}) + + self.xknx.connection_manager.register_connection_state_changed_cb( + self.connection_state_changed_cb + ) + + async def update(self) -> None: + """Update interface properties on new connection.""" + self.gateway_descriptor = await self.xknx.knxip_interface.gateway_info() + + self.device_registry.async_update_device( + device_id=self.device.id, + model=str(self.gateway_descriptor.name) + if self.gateway_descriptor + else None, + ) + + async def connection_state_changed_cb(self, state: XknxConnectionState) -> None: + """Call invoked after a KNX connection state change was received.""" + if state is XknxConnectionState.CONNECTED: + await self.update() diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 64cd6151f7c6..ef1539853425 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -1,9 +1,13 @@ """Support for KNX/IP sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta from typing import Any from xknx import XKNX +from xknx.core.connection_state import XknxConnectionState, XknxConnectionType from xknx.devices import Sensor as XknxSensor from homeassistant import config_entries @@ -11,12 +15,15 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, SensorEntity, + SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, + EntityCategory, Platform, ) from homeassistant.core import HomeAssistant @@ -24,10 +31,95 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum +from . import KNXModule from .const import ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN from .knx_entity import KnxEntity from .schema import SensorSchema +SCAN_INTERVAL = timedelta(seconds=10) + + +@dataclass +class KNXSystemEntityDescription(SensorEntityDescription): + """Class describing KNX system sensor entities.""" + + always_available: bool = True + entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + has_entity_name: bool = True + should_poll: bool = True + value_fn: Callable[[KNXModule], StateType | datetime] = lambda knx: None + + +SYSTEM_ENTITY_DESCRIPTIONS = ( + KNXSystemEntityDescription( + key="individual_address", + name="Individual Address", + always_available=False, + icon="mdi:router-network", + should_poll=False, + value_fn=lambda knx: str(knx.xknx.current_address), + ), + KNXSystemEntityDescription( + key="connected_since", + name="Connected since", + always_available=False, + device_class=SensorDeviceClass.TIMESTAMP, + should_poll=False, + value_fn=lambda knx: knx.xknx.connection_manager.connected_since, + ), + KNXSystemEntityDescription( + key="connection_type", + name="Connection type", + always_available=False, + device_class=SensorDeviceClass.ENUM, + options=[opt.value for opt in XknxConnectionType], + should_poll=False, + value_fn=lambda knx: knx.xknx.connection_manager.connection_type.value, # type: ignore[no-any-return] + ), + KNXSystemEntityDescription( + key="telegrams_incoming", + name="Telegrams incoming", + icon="mdi:upload-network", + entity_registry_enabled_default=False, + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_incoming, + ), + KNXSystemEntityDescription( + key="telegrams_incoming_error", + name="Telegrams incoming Error", + icon="mdi:help-network", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_incoming_error, + ), + KNXSystemEntityDescription( + key="telegrams_outgoing", + name="Telegrams outgoing", + icon="mdi:download-network", + entity_registry_enabled_default=False, + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing, + ), + KNXSystemEntityDescription( + key="telegrams_outgoing_error", + name="Telegrams outgoing Error", + icon="mdi:close-network", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing_error, + ), + KNXSystemEntityDescription( + key="telegram_count", + name="Telegrams", + icon="mdi:plus-network", + force_update=True, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda knx: knx.xknx.connection_manager.cemi_count_outgoing + + knx.xknx.connection_manager.cemi_count_incoming + + knx.xknx.connection_manager.cemi_count_incoming_error, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -35,10 +127,18 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor(s) for KNX platform.""" - xknx: XKNX = hass.data[DOMAIN].xknx - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SENSOR] + knx_module: KNXModule = hass.data[DOMAIN] - async_add_entities(KNXSensor(xknx, entity_config) for entity_config in config) + async_add_entities( + KNXSystemSensor(knx_module, description) + for description in SYSTEM_ENTITY_DESCRIPTIONS + ) + + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG].get(Platform.SENSOR) + if config: + async_add_entities( + KNXSensor(knx_module.xknx, entity_config) for entity_config in config + ) def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor: @@ -87,3 +187,48 @@ class KNXSensor(KnxEntity, SensorEntity): if self._device.last_telegram is not None: attr[ATTR_SOURCE] = str(self._device.last_telegram.source_address) return attr + + +class KNXSystemSensor(SensorEntity): + """Representation of a KNX system sensor.""" + + def __init__( + self, + knx: KNXModule, + description: KNXSystemEntityDescription, + ) -> None: + """Initialize of a KNX system sensor.""" + self.entity_description: KNXSystemEntityDescription = description + self.knx = knx + + self._attr_device_info = knx.interface_device.device_info + self._attr_should_poll = description.should_poll + self._attr_unique_id = f"_{knx.entry.entry_id}_{description.key}" + + @property + def native_value(self) -> StateType | datetime: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.knx) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.always_available: + return True + return self.knx.xknx.connection_manager.state is XknxConnectionState.CONNECTED + + async def after_update_callback(self, _: XknxConnectionState) -> None: + """Call after device was updated.""" + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Store register state change callback.""" + self.knx.xknx.connection_manager.register_connection_state_changed_cb( + self.after_update_callback + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect device object when removed.""" + self.knx.xknx.connection_manager.unregister_connection_state_changed_cb( + self.after_update_callback + ) diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index a67847d26fd9..9cf325086a2f 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import DEFAULT, AsyncMock, Mock, patch import pytest from xknx import XKNX -from xknx.core import XknxConnectionState +from xknx.core import XknxConnectionState, XknxConnectionType from xknx.dpt import DPTArray, DPTBinary from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from xknx.telegram import Telegram, TelegramDirection @@ -67,7 +67,8 @@ class KNXTestKit: # set XknxConnectionState.CONNECTED to avoid `unavailable` entities at startup # and start StateUpdater. This would be awaited on normal startup too. await self.xknx.connection_manager.connection_state_changed( - XknxConnectionState.CONNECTED + state=XknxConnectionState.CONNECTED, + connection_type=XknxConnectionType.TUNNEL_TCP, ) def knx_ip_interface_mock(): diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index daad5b091d28..61b7247037ed 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -38,7 +38,6 @@ async def test_binary_sensor_entity_category( ] } ) - assert len(hass.states.async_all()) == 1 await knx.assert_read("1/1/1") await knx.receive_response("1/1/1", True) @@ -65,7 +64,6 @@ async def test_binary_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: ] } ) - assert len(hass.states.async_all()) == 2 # StateUpdater initialize state await knx.assert_read("1/1/1") @@ -103,8 +101,6 @@ async def test_binary_sensor_ignore_internal_state( hass: HomeAssistant, knx: KNXTestKit ) -> None: """Test KNX binary_sensor with ignore_internal_state.""" - events = async_capture_events(hass, "state_changed") - await knx.setup_integration( { BinarySensorSchema.PLATFORM: [ @@ -122,39 +118,36 @@ async def test_binary_sensor_ignore_internal_state( ] } ) - assert len(hass.states.async_all()) == 2 - # binary_sensor defaults to STATE_OFF - state change form None - assert len(events) == 2 + events = async_capture_events(hass, "state_changed") # receive initial ON telegram await knx.receive_write("1/1/1", True) await knx.receive_write("2/2/2", True) await hass.async_block_till_done() - assert len(events) == 4 + assert len(events) == 2 # receive second ON telegram - ignore_internal_state shall force state_changed event await knx.receive_write("1/1/1", True) await knx.receive_write("2/2/2", True) await hass.async_block_till_done() - assert len(events) == 5 + assert len(events) == 3 # receive first OFF telegram await knx.receive_write("1/1/1", False) await knx.receive_write("2/2/2", False) await hass.async_block_till_done() - assert len(events) == 7 + assert len(events) == 5 # receive second OFF telegram - ignore_internal_state shall force state_changed event await knx.receive_write("1/1/1", False) await knx.receive_write("2/2/2", False) await hass.async_block_till_done() - assert len(events) == 8 + assert len(events) == 6 async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX binary_sensor with context timeout.""" async_fire_time_changed(hass, dt.utcnow()) - events = async_capture_events(hass, "state_changed") context_timeout = 1 await knx.setup_integration( @@ -169,9 +162,7 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No ] } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") # receive initial ON telegram await knx.receive_write("2/2/2", True) @@ -236,7 +227,6 @@ async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit) -> None ] } ) - assert len(hass.states.async_all()) == 1 # receive ON telegram await knx.receive_write("2/2/2", True) diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index 6f0fecf9d8d6..4fa8d02716fd 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -18,7 +18,6 @@ from tests.common import async_capture_events, async_fire_time_changed async def test_button_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX button with default payload.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ButtonSchema.PLATFORM: { @@ -27,9 +26,7 @@ async def test_button_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") # press button await hass.services.async_call( diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 582f082eb93d..240fde9ee8b8 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -20,7 +20,6 @@ async def test_climate_basic_temperature_set( hass: HomeAssistant, knx: KNXTestKit ) -> None: """Test KNX climate basic.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -31,9 +30,7 @@ async def test_climate_basic_temperature_set( } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") # read temperature await knx.assert_read("1/2/3") @@ -57,7 +54,6 @@ async def test_climate_basic_temperature_set( async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX climate hvac mode.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -72,9 +68,7 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater @@ -112,7 +106,6 @@ async def test_climate_preset_mode( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry ) -> None: """Test KNX climate preset mode.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -125,9 +118,7 @@ async def test_climate_preset_mode( } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater @@ -177,7 +168,6 @@ async def test_climate_preset_mode( async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test update climate entity for KNX.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -192,9 +182,7 @@ async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit) -> None: ) assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + async_capture_events(hass, "state_changed") await hass.async_block_till_done() # read states state updater diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index 5aef38ea00ad..4ee9bd04eee6 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -11,7 +11,6 @@ from tests.common import async_capture_events async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX cover basic.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { CoverSchema.PLATFORM: { @@ -25,9 +24,7 @@ async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 - assert len(events) == 1 - events.pop() + events = async_capture_events(hass, "state_changed") # read position state address and angle state address await knx.assert_read("1/0/2") diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 9bb6f22470a5..bec76f29eeca 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -28,7 +28,6 @@ async def test_binary_expose(hass: HomeAssistant, knx: KNXTestKit) -> None: } }, ) - assert not hass.states.async_all() # Change state to on hass.states.async_set(entity_id, "on", {}) @@ -57,7 +56,6 @@ async def test_expose_attribute(hass: HomeAssistant, knx: KNXTestKit) -> None: } }, ) - assert not hass.states.async_all() # Before init no response shall be sent await knx.receive_read("1/1/8") @@ -105,7 +103,6 @@ async def test_expose_attribute_with_default( } }, ) - assert not hass.states.async_all() # Before init default value shall be sent as response await knx.receive_read("1/1/8") @@ -152,7 +149,6 @@ async def test_expose_string(hass: HomeAssistant, knx: KNXTestKit) -> None: } }, ) - assert not hass.states.async_all() # Before init default value shall be sent as response await knx.receive_read("1/1/8") @@ -185,7 +181,6 @@ async def test_expose_cooldown(hass: HomeAssistant, knx: KNXTestKit) -> None: } }, ) - assert not hass.states.async_all() # Change state to 1 hass.states.async_set(entity_id, "1", {}) await knx.assert_write("1/1/8", (1,)) @@ -220,7 +215,6 @@ async def test_expose_conversion_exception( } }, ) - assert not hass.states.async_all() # Before init default value shall be sent as response await knx.receive_read("1/1/8") @@ -253,7 +247,6 @@ async def test_expose_with_date( } } ) - assert not hass.states.async_all() await knx.assert_write("1/1/8", (0x7A, 0x1, 0x7, 0xE9, 0xD, 0xE, 0x20, 0x80)) diff --git a/tests/components/knx/test_fan.py b/tests/components/knx/test_fan.py index 7a0859acc5f2..3e89aea72019 100644 --- a/tests/components/knx/test_fan.py +++ b/tests/components/knx/test_fan.py @@ -17,7 +17,6 @@ async def test_fan_percent(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 # turn on fan with default speed (50%) await hass.services.async_call( @@ -63,7 +62,6 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 # turn on fan with default speed (50% - step 2) await hass.services.async_call( @@ -116,7 +114,6 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 # turn on oscillation await hass.services.async_call( diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py new file mode 100644 index 000000000000..e45729559c1d --- /dev/null +++ b/tests/components/knx/test_interface_device.py @@ -0,0 +1,112 @@ +"""Test KNX scene.""" +from unittest.mock import patch + +from xknx.core import XknxConnectionState, XknxConnectionType +from xknx.telegram import IndividualAddress + +from homeassistant.components.knx.sensor import SCAN_INTERVAL +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt + +from .conftest import KNXTestKit + +from tests.common import async_capture_events, async_fire_time_changed + + +async def test_diagnostic_entities( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: + """Test diagnostic entities.""" + await knx.setup_integration({}) + + for entity_id in [ + "sensor.knx_interface_individual_address", + "sensor.knx_interface_connected_since", + "sensor.knx_interface_connection_type", + "sensor.knx_interface_telegrams_incoming", + "sensor.knx_interface_telegrams_incoming_error", + "sensor.knx_interface_telegrams_outgoing", + "sensor.knx_interface_telegrams_outgoing_error", + "sensor.knx_interface_telegrams", + ]: + entity = entity_registry.async_get(entity_id) + assert entity.entity_category is EntityCategory.DIAGNOSTIC + + for entity_id in [ + "sensor.knx_interface_telegrams_incoming", + "sensor.knx_interface_telegrams_outgoing", + ]: + entity = entity_registry.async_get(entity_id) + assert entity.disabled is True + + knx.xknx.connection_manager.cemi_count_incoming = 20 + knx.xknx.connection_manager.cemi_count_incoming_error = 1 + knx.xknx.connection_manager.cemi_count_outgoing = 10 + knx.xknx.connection_manager.cemi_count_outgoing_error = 2 + + events = async_capture_events(hass, "state_changed") + async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + assert len(events) == 3 # 5 polled sensors - 2 disabled + events.clear() + + for entity_id, test_state in [ + ("sensor.knx_interface_individual_address", "0.0.0"), + ("sensor.knx_interface_connection_type", "Tunnel TCP"), + # skipping connected_since timestamp + ("sensor.knx_interface_telegrams_incoming_error", "1"), + ("sensor.knx_interface_telegrams_outgoing_error", "2"), + ("sensor.knx_interface_telegrams", "31"), + ]: + assert hass.states.get(entity_id).state == test_state + + await knx.xknx.connection_manager.connection_state_changed( + state=XknxConnectionState.DISCONNECTED + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(events) == 4 # 3 not always_available + 3 force_update - 2 disabled + events.clear() + + knx.xknx.current_address = IndividualAddress("1.1.1") + await knx.xknx.connection_manager.connection_state_changed( + state=XknxConnectionState.CONNECTED, + connection_type=XknxConnectionType.TUNNEL_UDP, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + assert len(events) == 6 # all diagnostic sensors - counters are reset on connect + + for entity_id, test_state in [ + ("sensor.knx_interface_individual_address", "1.1.1"), + ("sensor.knx_interface_connection_type", "Tunnel UDP"), + # skipping connected_since timestamp + ("sensor.knx_interface_telegrams_incoming_error", "0"), + ("sensor.knx_interface_telegrams_outgoing_error", "0"), + ("sensor.knx_interface_telegrams", "0"), + ]: + assert hass.states.get(entity_id).state == test_state + + +async def test_removed_entity( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: + """Test unregister callback when entity is removed.""" + await knx.setup_integration({}) + + with patch.object( + knx.xknx.connection_manager, "unregister_connection_state_changed_cb" + ) as unregister_mock: + entity_registry.async_update_entity( + "sensor.knx_interface_connected_since", + disabled_by=er.RegistryEntryDisabler.USER, + ) + await hass.async_block_till_done() + unregister_mock.assert_called_once() diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index 491f5a3c1a94..a445d1a6fd35 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -36,7 +36,6 @@ async def test_light_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 knx.assert_state("light.test", STATE_OFF) # turn on light diff --git a/tests/components/knx/test_scene.py b/tests/components/knx/test_scene.py index f0381cc9cf28..8598ef0a627c 100644 --- a/tests/components/knx/test_scene.py +++ b/tests/components/knx/test_scene.py @@ -9,7 +9,9 @@ from homeassistant.helpers import entity_registry as er from .conftest import KNXTestKit -async def test_activate_knx_scene(hass: HomeAssistant, knx: KNXTestKit) -> None: +async def test_activate_knx_scene( + hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry +) -> None: """Test KNX scene.""" await knx.setup_integration( { @@ -23,10 +25,8 @@ async def test_activate_knx_scene(hass: HomeAssistant, knx: KNXTestKit) -> None: ] } ) - assert len(hass.states.async_all()) == 1 - registry = er.async_get(hass) - entity = registry.async_get("scene.test") + entity = entity_registry.async_get("scene.test") assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == "1/1/1_24" diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index d03fd41b0aaf..1c89338920ea 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -37,7 +37,6 @@ async def test_select_dpt_2_simple(hass: HomeAssistant, knx: KNXTestKit) -> None } } ) - assert len(hass.states.async_all()) == 1 state = hass.states.get("select.test") assert state.state is STATE_UNKNOWN @@ -152,7 +151,6 @@ async def test_select_dpt_20_103_all_options( } } ) - assert len(hass.states.async_all()) == 1 state = hass.states.get("select.test") assert state.state is STATE_UNKNOWN diff --git a/tests/components/knx/test_sensor.py b/tests/components/knx/test_sensor.py index ddccf299e3bb..10178324c93c 100644 --- a/tests/components/knx/test_sensor.py +++ b/tests/components/knx/test_sensor.py @@ -21,7 +21,6 @@ async def test_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 state = hass.states.get("sensor.test") assert state.state is STATE_UNKNOWN @@ -44,7 +43,6 @@ async def test_sensor(hass: HomeAssistant, knx: KNXTestKit) -> None: async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX sensor with always_callback.""" - events = async_capture_events(hass, "state_changed") await knx.setup_integration( { SensorSchema.PLATFORM: [ @@ -64,32 +62,30 @@ async def test_always_callback(hass: HomeAssistant, knx: KNXTestKit) -> None: ] } ) - assert len(hass.states.async_all()) == 2 - # state changes form None to "unknown" - assert len(events) == 2 + events = async_capture_events(hass, "state_changed") # receive initial telegram await knx.receive_write("1/1/1", (0x42,)) await knx.receive_write("2/2/2", (0x42,)) await hass.async_block_till_done() - assert len(events) == 4 + assert len(events) == 2 # receive second telegram with identical payload # always_callback shall force state_changed event await knx.receive_write("1/1/1", (0x42,)) await knx.receive_write("2/2/2", (0x42,)) await hass.async_block_till_done() - assert len(events) == 5 + assert len(events) == 3 # receive telegram with different payload await knx.receive_write("1/1/1", (0xFA,)) await knx.receive_write("2/2/2", (0xFA,)) await hass.async_block_till_done() - assert len(events) == 7 + assert len(events) == 5 # receive telegram with second payload again # always_callback shall force state_changed event await knx.receive_write("1/1/1", (0xFA,)) await knx.receive_write("2/2/2", (0xFA,)) await hass.async_block_till_done() - assert len(events) == 8 + assert len(events) == 6 diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py index 7293eee96c73..d68970537aba 100644 --- a/tests/components/knx/test_switch.py +++ b/tests/components/knx/test_switch.py @@ -23,7 +23,6 @@ async def test_switch_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 # turn on switch await hass.services.async_call( @@ -66,7 +65,6 @@ async def test_switch_state(hass: HomeAssistant, knx: KNXTestKit) -> None: }, } ) - assert len(hass.states.async_all()) == 1 # StateUpdater initialize state await knx.assert_read(_STATE_ADDRESS) diff --git a/tests/components/knx/test_weather.py b/tests/components/knx/test_weather.py index d9128a8c071b..8aaf4fa43382 100644 --- a/tests/components/knx/test_weather.py +++ b/tests/components/knx/test_weather.py @@ -35,7 +35,6 @@ async def test_weather(hass: HomeAssistant, knx: KNXTestKit) -> None: } } ) - assert len(hass.states.async_all()) == 1 state = hass.states.get("weather.test") assert state.state is ATTR_CONDITION_EXCEPTIONAL From d7de23fa6506b9e0f21c914966e2f6d3d639f25b Mon Sep 17 00:00:00 2001 From: Jesse Moody Date: Sun, 19 Mar 2023 16:53:21 -0400 Subject: [PATCH 0598/1058] Adjust eventloop -> event loop spelling (#89931) eventloop -> event loop spelling --- homeassistant/config_entries.py | 2 +- homeassistant/core.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 41cccdf9969c..3ab16f69676d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -752,7 +752,7 @@ class ConfigEntry: target: Coroutine[Any, Any, _R], name: str | None = None, ) -> asyncio.Task[_R]: - """Create a task from within the eventloop. + """Create a task from within the event loop. This method must be run in the event loop. diff --git a/homeassistant/core.py b/homeassistant/core.py index bfccb721d8d1..900355d4a5d3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -516,7 +516,7 @@ class HomeAssistant: def async_create_task( self, target: Coroutine[Any, Any, _R], name: str | None = None ) -> asyncio.Task[_R]: - """Create a task from within the eventloop. + """Create a task from within the event loop. This method must be run in the event loop. If you are using this in your integration, use the create task methods on the config entry instead. @@ -534,7 +534,7 @@ class HomeAssistant: target: Coroutine[Any, Any, _R], name: str, ) -> asyncio.Task[_R]: - """Create a task from within the eventloop. + """Create a task from within the event loop. This is a background task which will not block startup and will be automatically cancelled on shutdown. If you are using this in your From 5ffb2330043ecfe36a4c8272d239b0883a5d6339 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:01:16 -1000 Subject: [PATCH 0599/1058] Avoid database executor job to fetch statistic metadata on cache hit (#89960) * Avoid database executor job to fetch statistic metadata on cache hit Since we will almost always have a cache hit fetching statistic meta data we can avoid an executor job * Avoid database executor job to fetch statistic metadata on cache hit Since we will almost always have a cache hit fetching statistic meta data we can avoid an executor job * Avoid database executor job to fetch statistic metadata on cache hit Since we will almost always have a cache hit fetching statistic meta data we can avoid an executor job * remove exception catch since the threading.excepthook will actually catch this in production * fix a few missed ones * threadsafe * Update homeassistant/components/recorder/table_managers/statistics_meta.py * coverage and optimistic caching --- homeassistant/components/energy/validate.py | 2 +- .../components/energy/websocket_api.py | 4 +- homeassistant/components/recorder/core.py | 1 + .../components/recorder/statistics.py | 167 ++++++++++++------ .../table_managers/statistics_meta.py | 64 ++++--- .../components/recorder/websocket_api.py | 16 +- homeassistant/components/sensor/recorder.py | 6 +- homeassistant/components/tibber/sensor.py | 2 +- tests/components/recorder/common.py | 4 +- tests/components/recorder/db_schema_28.py | 9 + .../table_managers/test_statistics_meta.py | 4 +- tests/components/recorder/test_statistics.py | 57 ++++-- .../components/recorder/test_websocket_api.py | 8 +- tests/components/sensor/test_recorder.py | 4 +- tests/components/tibber/test_statistics.py | 2 +- 15 files changed, 232 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index a2c3ad094da7..0a89c3d92706 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -603,7 +603,7 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: functools.partial( recorder.statistics.get_metadata, hass, - statistic_ids=list(wanted_statistics_metadata), + statistic_ids=set(wanted_statistics_metadata), ) ) ) diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 15ffc6a2804b..7830d3649f26 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -262,8 +262,8 @@ async def ws_get_fossil_energy_consumption( connection.send_error(msg["id"], "invalid_end_time", "Invalid end_time") return - statistic_ids = list(msg["energy_statistic_ids"]) - statistic_ids.append(msg["co2_statistic_id"]) + statistic_ids = set(msg["energy_statistic_ids"]) + statistic_ids.add(msg["co2_statistic_id"]) # Fetch energy + CO2 statistics statistics = await recorder.get_instance(hass).async_add_executor_job( diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 538d07eb4d76..30dd311c0e62 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -501,6 +501,7 @@ class Recorder(threading.Thread): new_size = self.hass.states.async_entity_ids_count() * 2 self.state_attributes_manager.adjust_lru_size(new_size) self.states_meta_manager.adjust_lru_size(new_size) + self.statistics_meta_manager.adjust_lru_size(new_size) @callback def async_periodic_statistics(self) -> None: diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index fcd934270d16..2f2deeeaeee5 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -713,10 +713,10 @@ def compile_missing_statistics(instance: Recorder) -> bool: periods_without_commit += 1 end = start + timedelta(minutes=period_size) _LOGGER.debug("Compiling missing statistics for %s-%s", start, end) - metadata_modified = _compile_statistics( + modified_statistic_ids = _compile_statistics( instance, session, start, end >= last_period ) - if periods_without_commit == commit_interval or metadata_modified: + if periods_without_commit == commit_interval or modified_statistic_ids: session.commit() session.expunge_all() periods_without_commit = 0 @@ -736,29 +736,40 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - session=instance.get_session(), exception_filter=_filter_unique_constraint_integrity_error(instance), ) as session: - _compile_statistics(instance, session, start, fire_events) + modified_statistic_ids = _compile_statistics( + instance, session, start, fire_events + ) + + if modified_statistic_ids: + # In the rare case that we have modified statistic_ids, we reload the modified + # statistics meta data into the cache in a fresh session to ensure that the + # cache is up to date and future calls to get statistics meta data will + # not have to hit the database again. + with session_scope(session=instance.get_session(), read_only=True) as session: + instance.statistics_meta_manager.get_many(session, modified_statistic_ids) + return True def _compile_statistics( instance: Recorder, session: Session, start: datetime, fire_events: bool -) -> bool: +) -> set[str]: """Compile 5-minute statistics for all integrations with a recorder platform. This is a helper function for compile_statistics and compile_missing_statistics that does not retry on database errors since both callers already retry. - returns True if metadata was modified, False otherwise + returns a set of modified statistic_ids if any were modified. """ assert start.tzinfo == dt_util.UTC, "start must be in UTC" end = start + timedelta(minutes=5) statistics_meta_manager = instance.statistics_meta_manager - metadata_modified = False + modified_statistic_ids: set[str] = set() # Return if we already have 5-minute statistics for the requested period if session.query(StatisticsRuns).filter_by(start=start).first(): _LOGGER.debug("Statistics already compiled for %s-%s", start, end) - return metadata_modified + return modified_statistic_ids _LOGGER.debug("Compiling statistics for %s-%s", start, end) platform_stats: list[StatisticResult] = [] @@ -782,10 +793,11 @@ def _compile_statistics( # Insert collected statistics in the database for stats in platform_stats: - updated, metadata_id = statistics_meta_manager.update_or_add( + modified_statistic_id, metadata_id = statistics_meta_manager.update_or_add( session, stats["meta"], current_metadata ) - metadata_modified |= updated + if modified_statistic_id is not None: + modified_statistic_ids.add(modified_statistic_id) _insert_statistics( session, StatisticsShortTerm, @@ -804,7 +816,7 @@ def _compile_statistics( if start.minute == 55: instance.hass.bus.fire(EVENT_RECORDER_HOURLY_STATISTICS_GENERATED) - return metadata_modified + return modified_statistic_ids def _adjust_sum_statistics( @@ -882,7 +894,7 @@ def get_metadata_with_session( instance: Recorder, session: Session, *, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -903,7 +915,7 @@ def get_metadata_with_session( def get_metadata( hass: HomeAssistant, *, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -947,9 +959,79 @@ def update_statistics_metadata( ) +async def async_list_statistic_ids( + hass: HomeAssistant, + statistic_ids: set[str] | None = None, + statistic_type: Literal["mean"] | Literal["sum"] | None = None, +) -> list[dict]: + """Return all statistic_ids (or filtered one) and unit of measurement. + + Queries the database for existing statistic_ids, as well as integrations with + a recorder platform for statistic_ids which will be added in the next statistics + period. + """ + instance = get_instance(hass) + + if statistic_ids is not None: + # Try to get the results from the cache since there is nearly + # always a cache hit. + statistics_meta_manager = instance.statistics_meta_manager + metadata = statistics_meta_manager.get_from_cache_threadsafe(statistic_ids) + if not statistic_ids.difference(metadata): + result = _statistic_by_id_from_metadata(hass, metadata) + return _flatten_list_statistic_ids_metadata_result(result) + + return await instance.async_add_executor_job( + list_statistic_ids, + hass, + statistic_ids, + statistic_type, + ) + + +def _statistic_by_id_from_metadata( + hass: HomeAssistant, + metadata: dict[str, tuple[int, StatisticMetaData]], +) -> dict[str, dict[str, Any]]: + """Return a list of results for a given metadata dict.""" + return { + meta["statistic_id"]: { + "display_unit_of_measurement": get_display_unit( + hass, meta["statistic_id"], meta["unit_of_measurement"] + ), + "has_mean": meta["has_mean"], + "has_sum": meta["has_sum"], + "name": meta["name"], + "source": meta["source"], + "unit_class": _get_unit_class(meta["unit_of_measurement"]), + "unit_of_measurement": meta["unit_of_measurement"], + } + for _, meta in metadata.values() + } + + +def _flatten_list_statistic_ids_metadata_result( + result: dict[str, dict[str, Any]] +) -> list[dict]: + """Return a flat dict of metadata.""" + return [ + { + "statistic_id": _id, + "display_unit_of_measurement": info["display_unit_of_measurement"], + "has_mean": info["has_mean"], + "has_sum": info["has_sum"], + "name": info.get("name"), + "source": info["source"], + "statistics_unit_of_measurement": info["unit_of_measurement"], + "unit_class": info["unit_class"], + } + for _id, info in result.items() + ] + + def list_statistic_ids( hass: HomeAssistant, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, ) -> list[dict]: """Return all statistic_ids (or filtered one) and unit of measurement. @@ -959,30 +1041,17 @@ def list_statistic_ids( period. """ result = {} - statistic_ids_set = set(statistic_ids) if statistic_ids else None + instance = get_instance(hass) + statistics_meta_manager = instance.statistics_meta_manager # Query the database with session_scope(hass=hass, read_only=True) as session: - metadata = get_instance(hass).statistics_meta_manager.get_many( + metadata = statistics_meta_manager.get_many( session, statistic_type=statistic_type, statistic_ids=statistic_ids ) + result = _statistic_by_id_from_metadata(hass, metadata) - result = { - meta["statistic_id"]: { - "display_unit_of_measurement": get_display_unit( - hass, meta["statistic_id"], meta["unit_of_measurement"] - ), - "has_mean": meta["has_mean"], - "has_sum": meta["has_sum"], - "name": meta["name"], - "source": meta["source"], - "unit_class": _get_unit_class(meta["unit_of_measurement"]), - "unit_of_measurement": meta["unit_of_measurement"], - } - for _, meta in metadata.values() - } - - if not statistic_ids_set or statistic_ids_set.difference(result): + if not statistic_ids or statistic_ids.difference(result): # If we want all statistic_ids, or some are missing, we need to query # the integrations for the missing ones. # @@ -1009,19 +1078,7 @@ def list_statistic_ids( } # Return a list of statistic_id + metadata - return [ - { - "statistic_id": _id, - "display_unit_of_measurement": info["display_unit_of_measurement"], - "has_mean": info["has_mean"], - "has_sum": info["has_sum"], - "name": info.get("name"), - "source": info["source"], - "statistics_unit_of_measurement": info["unit_of_measurement"], - "unit_class": info["unit_class"], - } - for _id, info in result.items() - ] + return _flatten_list_statistic_ids_metadata_result(result) def _reduce_statistics( @@ -1698,7 +1755,7 @@ def _statistics_during_period_with_session( session: Session, start_time: datetime, end_time: datetime | None, - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], @@ -1708,6 +1765,10 @@ def _statistics_during_period_with_session( If end_time is omitted, returns statistics newer than or equal to start_time. If statistic_ids is omitted, returns statistics for all statistics ids. """ + if statistic_ids is not None and not isinstance(statistic_ids, set): + # This is for backwards compatibility to avoid a breaking change + # for custom integrations that call this method. + statistic_ids = set(statistic_ids) # type: ignore[unreachable] metadata = None # Fetch metadata for the given (or all) statistic_ids metadata = get_instance(hass).statistics_meta_manager.get_many( @@ -1784,7 +1845,7 @@ def statistics_during_period( hass: HomeAssistant, start_time: datetime, end_time: datetime | None, - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], @@ -1845,7 +1906,7 @@ def _get_last_statistics( types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> dict[str, list[StatisticsRow]]: """Return the last number_of_stats statistics for a given statistic_id.""" - statistic_ids = [statistic_id] + statistic_ids = {statistic_id} with session_scope(hass=hass, read_only=True) as session: # Fetch metadata for the given statistic_id metadata = get_instance(hass).statistics_meta_manager.get_many( @@ -1930,7 +1991,7 @@ def _latest_short_term_statistics_stmt( def get_latest_short_term_statistics( hass: HomeAssistant, - statistic_ids: list[str], + statistic_ids: set[str], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], metadata: dict[str, tuple[int, StatisticMetaData]] | None = None, ) -> dict[str, list[StatisticsRow]]: @@ -2031,7 +2092,7 @@ def _sorted_statistics_to_dict( hass: HomeAssistant, session: Session, stats: Sequence[Row[Any]], - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, _metadata: dict[str, tuple[int, StatisticMetaData]], convert_units: bool, table: type[StatisticsBase], @@ -2294,7 +2355,7 @@ def _import_statistics_with_session( """Import statistics to the database.""" statistics_meta_manager = instance.statistics_meta_manager old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids=[metadata["statistic_id"]] + session, statistic_ids={metadata["statistic_id"]} ) _, metadata_id = statistics_meta_manager.update_or_add( session, metadata, old_metadata_dict @@ -2338,7 +2399,7 @@ def adjust_statistics( with session_scope(session=instance.get_session()) as session: metadata = instance.statistics_meta_manager.get_many( - session, statistic_ids=[statistic_id] + session, statistic_ids={statistic_id} ) if statistic_id not in metadata: return True @@ -2476,7 +2537,7 @@ def _validate_db_schema_utf8( try: with session_scope(session=session_maker()) as session: old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids=[statistic_id] + session, statistic_ids={statistic_id} ) try: statistics_meta_manager.update_or_add( @@ -2573,7 +2634,7 @@ def _validate_db_schema( session, start_time, None, - [statistic_id], + {statistic_id}, "hour" if table == Statistics else "5minute", None, {"last_reset", "max", "mean", "min", "state", "sum"}, diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index 93417b432535..ba47b3600d66 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -34,7 +34,7 @@ QUERY_STATISTIC_META = ( def _generate_get_metadata_stmt( - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> StatementLambdaElement: @@ -89,7 +89,7 @@ class StatisticsMetaManager: def _get_from_database( self, session: Session, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -112,6 +112,7 @@ class StatisticsMetaManager: ): statistics_meta = cast(StatisticsMeta, row) id_meta = _statistics_meta_to_id_statistics_metadata(statistics_meta) + statistic_id = cast(str, statistics_meta.statistic_id) results[statistic_id] = id_meta if update_cache: @@ -149,7 +150,7 @@ class StatisticsMetaManager: statistic_id: str, new_metadata: StatisticMetaData, old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], - ) -> tuple[bool, int]: + ) -> tuple[str | None, int]: """Update metadata in the database. This call is not thread-safe and must be called from the @@ -163,7 +164,7 @@ class StatisticsMetaManager: or old_metadata["unit_of_measurement"] != new_metadata["unit_of_measurement"] ): - return False, metadata_id + return None, metadata_id self._assert_in_recorder_thread() session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update( @@ -182,7 +183,7 @@ class StatisticsMetaManager: old_metadata, new_metadata, ) - return True, metadata_id + return statistic_id, metadata_id def load(self, session: Session) -> None: """Load the statistic_id to metadata_id mapping into memory. @@ -196,12 +197,12 @@ class StatisticsMetaManager: self, session: Session, statistic_id: str ) -> tuple[int, StatisticMetaData] | None: """Resolve statistic_id to the metadata_id.""" - return self.get_many(session, [statistic_id]).get(statistic_id) + return self.get_many(session, {statistic_id}).get(statistic_id) def get_many( self, session: Session, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, ) -> dict[str, tuple[int, StatisticMetaData]]: @@ -228,16 +229,8 @@ class StatisticsMetaManager: "Providing statistic_type and statistic_source is mutually exclusive of statistic_ids" ) - results: dict[str, tuple[int, StatisticMetaData]] = {} - missing_statistic_id: list[str] = [] - - for statistic_id in statistic_ids: - if id_meta := self._stat_id_to_id_meta.get(statistic_id): - results[statistic_id] = id_meta - else: - missing_statistic_id.append(statistic_id) - - if not missing_statistic_id: + results = self.get_from_cache_threadsafe(statistic_ids) + if not (missing_statistic_id := statistic_ids.difference(results)): return results # Fetch metadata from the database @@ -245,12 +238,29 @@ class StatisticsMetaManager: session, statistic_ids=missing_statistic_id ) + def get_from_cache_threadsafe( + self, statistic_ids: set[str] + ) -> dict[str, tuple[int, StatisticMetaData]]: + """Get metadata from cache. + + This call is thread safe and can be run in the event loop, + the database executor, or the recorder thread. + """ + return { + statistic_id: id_meta + for statistic_id in statistic_ids + # We must use a get call here and never iterate over the dict + # because the dict can be modified by the recorder thread + # while we are iterating over it. + if (id_meta := self._stat_id_to_id_meta.get(statistic_id)) + } + def update_or_add( self, session: Session, new_metadata: StatisticMetaData, old_metadata_dict: dict[str, tuple[int, StatisticMetaData]], - ) -> tuple[bool, int]: + ) -> tuple[str | None, int]: """Get metadata_id for a statistic_id. If the statistic_id is previously unknown, add it. If it's already known, update @@ -258,16 +268,16 @@ class StatisticsMetaManager: Updating metadata source is not possible. - Returns a tuple of (updated, metadata_id). + Returns a tuple of (statistic_id | None, metadata_id). - updated is True if the metadata was updated, False if it was not updated. + statistic_id is None if the metadata was not updated This call is not thread-safe and must be called from the recorder thread. """ statistic_id = new_metadata["statistic_id"] if statistic_id not in old_metadata_dict: - return True, self._add_metadata(session, statistic_id, new_metadata) + return statistic_id, self._add_metadata(session, statistic_id, new_metadata) return self._update_metadata( session, statistic_id, new_metadata, old_metadata_dict ) @@ -319,4 +329,14 @@ class StatisticsMetaManager: def reset(self) -> None: """Reset the cache.""" - self._stat_id_to_id_meta = {} + self._stat_id_to_id_meta.clear() + + def adjust_lru_size(self, new_size: int) -> None: + """Adjust the LRU cache size. + + This call is not thread-safe and must be called from the + recorder thread. + """ + lru: LRU = self._stat_id_to_id_meta + if new_size > lru.get_size(): + lru.set_size(new_size) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 29c0808e6ad9..df42c519fe2c 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -37,6 +37,7 @@ from .statistics import ( async_add_external_statistics, async_change_statistics_unit, async_import_statistics, + async_list_statistic_ids, list_statistic_ids, statistic_during_period, statistics_during_period, @@ -151,7 +152,7 @@ def _ws_get_statistics_during_period( msg_id: int, start_time: dt, end_time: dt | None, - statistic_ids: list[str] | None, + statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], @@ -208,7 +209,7 @@ async def ws_handle_get_statistics_during_period( msg["id"], start_time, end_time, - msg["statistic_ids"], + set(msg["statistic_ids"]), msg.get("period"), msg.get("units"), types, @@ -329,11 +330,10 @@ async def ws_get_statistics_metadata( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Get metadata for a list of statistic_ids.""" - instance = get_instance(hass) - statistic_ids = await instance.async_add_executor_job( - list_statistic_ids, hass, msg.get("statistic_ids") - ) - connection.send_result(msg["id"], statistic_ids) + statistic_ids = msg.get("statistic_ids") + statistic_ids_set_or_none = set(statistic_ids) if statistic_ids else None + metadata = await async_list_statistic_ids(hass, statistic_ids_set_or_none) + connection.send_result(msg["id"], metadata) @websocket_api.require_admin @@ -413,7 +413,7 @@ async def ws_adjust_sum_statistics( instance = get_instance(hass) metadatas = await instance.async_add_executor_job( - list_statistic_ids, hass, (msg["statistic_id"],) + list_statistic_ids, hass, {msg["statistic_id"]} ) if not metadatas: connection.send_error(msg["id"], "unknown_statistic_id", "Unknown statistic ID") diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 8d5af155fd74..c0df642ed361 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -453,10 +453,10 @@ def _compile_statistics( # noqa: C901 # that are not in the metadata table and we are not working # with them anyway. old_metadatas = statistics.get_metadata_with_session( - get_instance(hass), session, statistic_ids=list(entities_with_float_states) + get_instance(hass), session, statistic_ids=set(entities_with_float_states) ) to_process: list[tuple[str, str | None, str, list[tuple[float, State]]]] = [] - to_query: list[str] = [] + to_query: set[str] = set() for _state in sensor_states: entity_id = _state.entity_id if not (maybe_float_states := entities_with_float_states.get(entity_id)): @@ -472,7 +472,7 @@ def _compile_statistics( # noqa: C901 state_class: str = _state.attributes[ATTR_STATE_CLASS] to_process.append((entity_id, statistics_unit, state_class, valid_float_states)) if "sum" in wanted_statistics[entity_id]: - to_query.append(entity_id) + to_query.add(entity_id) last_stats = statistics.get_latest_short_term_statistics( hass, to_query, {"last_reset", "state", "sum"}, metadata=old_metadatas diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 4d847c19205d..874ec5be6735 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -636,7 +636,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): self.hass, start, None, - [statistic_id], + {statistic_id}, "hour", None, {"sum"}, diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index aec5bf81349f..17e8c47f6b40 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -144,13 +144,15 @@ def statistics_during_period( hass: HomeAssistant, start_time: datetime, end_time: datetime | None = None, - statistic_ids: list[str] | None = None, + statistic_ids: set[str] | None = None, period: Literal["5minute", "day", "hour", "week", "month"] = "hour", units: dict[str, str] | None = None, types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]] | None = None, ) -> dict[str, list[dict[str, Any]]]: """Call statistics_during_period with defaults for simpler tests.""" + if statistic_ids is not None and not isinstance(statistic_ids, set): + statistic_ids = set(statistic_ids) if types is None: types = {"last_reset", "max", "mean", "min", "state", "sum"} return statistics.statistics_during_period( diff --git a/tests/components/recorder/db_schema_28.py b/tests/components/recorder/db_schema_28.py index d7a9ec0af4ec..8127cb3f26f7 100644 --- a/tests/components/recorder/db_schema_28.py +++ b/tests/components/recorder/db_schema_28.py @@ -292,6 +292,15 @@ class States(Base): # type: ignore[misc,valid-type] context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) origin_idx = Column(SmallInteger) # 0 is local, 1 is remote + context_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + context_user_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok + context_parent_id_bin = Column( + LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) + ) # *** Not originally in v28, only added for recorder to startup ok metadata_id = Column( Integer, ForeignKey("states_meta.metadata_id"), index=True ) # *** Not originally in v28, only added for recorder to startup ok diff --git a/tests/components/recorder/table_managers/test_statistics_meta.py b/tests/components/recorder/table_managers/test_statistics_meta.py index 8ec3f9367d64..ab6615c6dd0a 100644 --- a/tests/components/recorder/table_managers/test_statistics_meta.py +++ b/tests/components/recorder/table_managers/test_statistics_meta.py @@ -26,12 +26,12 @@ async def test_passing_mutually_exclusive_options_to_get_many( ) with pytest.raises(ValueError): instance.statistics_meta_manager.get_many( - session, statistic_ids=["light.kitchen"], statistic_source="sensor" + session, statistic_ids={"light.kitchen"}, statistic_source="sensor" ) assert ( instance.statistics_meta_manager.get_many( session, - statistic_ids=["light.kitchen"], + statistic_ids={"light.kitchen"}, ) == {} ) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 522a3eff2e6d..de75052d3449 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -84,7 +84,7 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) # Should not fail if there is nothing there yet stats = get_latest_short_term_statistics( - hass, ["sensor.test1"], {"last_reset", "max", "mean", "min", "state", "sum"} + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} ) assert stats == {} @@ -169,15 +169,15 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) assert stats == {"sensor.test1": [expected_2]} stats = get_latest_short_term_statistics( - hass, ["sensor.test1"], {"last_reset", "max", "mean", "min", "state", "sum"} + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} ) assert stats == {"sensor.test1": [expected_2]} - metadata = get_metadata(hass, statistic_ids=['sensor.test1"']) + metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) stats = get_latest_short_term_statistics( hass, - ["sensor.test1"], + {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"}, metadata=metadata, ) @@ -213,7 +213,7 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) instance.get_session().query(StatisticsShortTerm).delete() # Should not fail there is nothing in the table stats = get_latest_short_term_statistics( - hass, ["sensor.test1"], {"last_reset", "max", "mean", "min", "state", "sum"} + hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} ) assert stats == {} @@ -243,7 +243,7 @@ def mock_sensor_statistics(): sensor_stats("sensor.test3", start), ], get_metadata( - _hass, statistic_ids=["sensor.test1", "sensor.test2", "sensor.test3"] + _hass, statistic_ids={"sensor.test1", "sensor.test2", "sensor.test3"} ), ) @@ -385,6 +385,27 @@ def test_rename_entity(hass_recorder: Callable[..., HomeAssistant]) -> None: assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} +def test_statistics_during_period_set_back_compat( + hass_recorder: Callable[..., HomeAssistant] +) -> None: + """Test statistics_during_period can handle a list instead of a set.""" + hass = hass_recorder() + setup_component(hass, "sensor", {}) + # This should not throw an exception when passed a list instead of a set + assert ( + statistics.statistics_during_period( + hass, + dt_util.utcnow(), + None, + statistic_ids=["sensor.test1"], + period="5minute", + units=None, + types=set(), + ) + == {} + ) + + def test_rename_entity_collision( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture ) -> None: @@ -595,7 +616,7 @@ async def test_import_statistics( "unit_class": "energy", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -692,7 +713,7 @@ async def test_import_statistics( "unit_class": "energy", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -814,7 +835,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} # Attempt to insert statistics for the wrong domain external_metadata = {**_external_metadata, "source": "other"} @@ -824,7 +845,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} # Attempt to insert statistics for a naive starting time external_metadata = {**_external_metadata} @@ -837,7 +858,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} # Attempt to insert statistics for an invalid starting time external_metadata = {**_external_metadata} @@ -847,7 +868,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} # Attempt to insert statistics with a naive last_reset external_metadata = {**_external_metadata} @@ -860,7 +881,7 @@ def test_external_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} def test_import_statistics_errors( @@ -903,7 +924,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("test:total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"test:total_energy_import"}) == {} # Attempt to insert statistics for the wrong domain external_metadata = {**_external_metadata, "source": "sensor"} @@ -913,7 +934,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} # Attempt to insert statistics for a naive starting time external_metadata = {**_external_metadata} @@ -926,7 +947,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} # Attempt to insert statistics for an invalid starting time external_metadata = {**_external_metadata} @@ -936,7 +957,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} # Attempt to insert statistics with a naive last_reset external_metadata = {**_external_metadata} @@ -949,7 +970,7 @@ def test_import_statistics_errors( wait_recording_done(hass) assert statistics_during_period(hass, zero, period="hour") == {} assert list_statistic_ids(hass) == [] - assert get_metadata(hass, statistic_ids=("sensor.total_energy_import",)) == {} + assert get_metadata(hass, statistic_ids={"sensor.total_energy_import"}) == {} @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 4ed6747ac0bf..5244a33f0bcd 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -2588,7 +2588,7 @@ async def test_import_statistics( "unit_class": "energy", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -2820,7 +2820,7 @@ async def test_adjust_sum_statistics_energy( "unit_class": "energy", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -3016,7 +3016,7 @@ async def test_adjust_sum_statistics_gas( "unit_class": "volume", } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, @@ -3230,7 +3230,7 @@ async def test_adjust_sum_statistics_errors( "unit_class": unit_class, } ] - metadata = get_metadata(hass, statistic_ids=(statistic_id,)) + metadata = get_metadata(hass, statistic_ids={statistic_id}) assert metadata == { statistic_id: ( 1, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index ae044c535b5d..8881bef8edc9 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -3067,7 +3067,7 @@ def test_compile_hourly_statistics_changing_state_class( "unit_class": unit_class, }, ] - metadata = get_metadata(hass, statistic_ids=("sensor.test1",)) + metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) assert metadata == { "sensor.test1": ( 1, @@ -3103,7 +3103,7 @@ def test_compile_hourly_statistics_changing_state_class( "unit_class": unit_class, }, ] - metadata = get_metadata(hass, statistic_ids=("sensor.test1",)) + metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) assert metadata == { "sensor.test1": ( 1, diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index ca6500e6327f..6de7549c285b 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -35,7 +35,7 @@ async def test_async_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) - hass, dt_util.parse_datetime(data[0]["from"]), None, - [statistic_id], + {statistic_id}, "hour", None, {"start", "state", "mean", "min", "max", "last_reset", "sum"}, From 7f3e4cb3afcfced7be09b17ffb444fa555860344 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:03:12 -1000 Subject: [PATCH 0600/1058] Guard against selecting all invalid entity_ids in history (#89929) If all the entity_ids that were provided do not exist we would end up passing an empty list of ids to the SQL query which would do an unbounded select --- homeassistant/components/logbook/processor.py | 12 +++---- .../components/recorder/history/modern.py | 32 +++++++++---------- .../components/recorder/models/__init__.py | 3 +- .../components/recorder/models/state.py | 11 +++++++ 4 files changed, 33 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index e053dcb08191..32301e98358a 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -14,6 +14,7 @@ from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ( bytes_to_uuid_hex_or_none, + extract_metadata_ids, process_datetime_to_timestamp, process_timestamp_to_utc_isoformat, ) @@ -154,14 +155,11 @@ class EventProcessor: metadata_ids: list[int] | None = None if self.entity_ids: instance = get_instance(self.hass) - entity_id_to_metadata_id = instance.states_meta_manager.get_many( - self.entity_ids, session, False + metadata_ids = extract_metadata_ids( + instance.states_meta_manager.get_many( + self.entity_ids, session, False + ) ) - metadata_ids = [ - metadata_id - for metadata_id in entity_id_to_metadata_id.values() - if metadata_id is not None - ] stmt = statement_for_request( start_day, end_day, diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index a6ca9adf7b51..d6269d21b23a 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -23,7 +23,12 @@ import homeassistant.util.dt as dt_util from ... import recorder from ..db_schema import RecorderRuns, StateAttributes, States, StatesMeta from ..filters import Filters -from ..models import LazyState, process_timestamp, row_to_compressed_state +from ..models import ( + LazyState, + extract_metadata_ids, + process_timestamp, + row_to_compressed_state, +) from ..util import execute_stmt_lambda_element, session_scope from .const import ( IGNORE_DOMAINS_ENTITY_ID_LIKE, @@ -232,14 +237,12 @@ def get_significant_states_with_session( entity_id_to_metadata_id: dict[str, int | None] | None = None if entity_ids: instance = recorder.get_instance(hass) - entity_id_to_metadata_id = instance.states_meta_manager.get_many( - entity_ids, session, False - ) - metadata_ids = [ - metadata_id - for metadata_id in entity_id_to_metadata_id.values() - if metadata_id is not None - ] + if not ( + entity_id_to_metadata_id := instance.states_meta_manager.get_many( + entity_ids, session, False + ) + ) or not (metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): + return {} stmt = _significant_states_stmt( start_time, end_time, @@ -569,14 +572,9 @@ def _get_rows_with_session( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. if entity_ids: - if not entity_id_to_metadata_id: - return [] - metadata_ids = [ - metadata_id - for metadata_id in entity_id_to_metadata_id.values() - if metadata_id is not None - ] - if not metadata_ids: + if not entity_id_to_metadata_id or not ( + metadata_ids := extract_metadata_ids(entity_id_to_metadata_id) + ): return [] stmt = _get_states_for_entities_stmt( run.start, utc_point_in_time, metadata_ids, no_attributes diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py index 3aec02b8d4b7..91dd80c4aa2e 100644 --- a/homeassistant/components/recorder/models/__init__.py +++ b/homeassistant/components/recorder/models/__init__.py @@ -8,7 +8,7 @@ from .context import ( uuid_hex_to_bytes_or_none, ) from .database import DatabaseEngine, DatabaseOptimizer, UnsupportedDialect -from .state import LazyState, row_to_compressed_state +from .state import LazyState, extract_metadata_ids, row_to_compressed_state from .statistics import ( CalendarStatisticPeriod, FixedStatisticPeriod, @@ -43,6 +43,7 @@ __all__ = [ "bytes_to_ulid_or_none", "bytes_to_uuid_hex_or_none", "datetime_to_timestamp_or_none", + "extract_metadata_ids", "process_datetime_to_timestamp", "process_timestamp", "process_timestamp_to_utc_isoformat", diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index c70e43426356..5594f5f6d437 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -24,6 +24,17 @@ from .time import process_timestamp _LOGGER = logging.getLogger(__name__) +def extract_metadata_ids( + entity_id_to_metadata_id: dict[str, int | None], +) -> list[int]: + """Extract metadata ids from entity_id_to_metadata_id.""" + return [ + metadata_id + for metadata_id in entity_id_to_metadata_id.values() + if metadata_id is not None + ] + + class LazyState(State): """A lazy version of core State after schema 31.""" From aebe4c66a67ec08620c5fb1c4bd2962ba7ab2780 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:04:24 -1000 Subject: [PATCH 0601/1058] Fix cpu thrashing during purge after all legacy events were removed (#89923) * Fix cpu thrashing during purge after all legacy events were removed We now remove the the index of of event ids on the states table when its all NULLs to save space. The purge path needs to avoid checking for legacy rows to purge if the index has been removed since it will result in a full table scan each purge cycle that will always find no legacy rows to purge * one more place * drop the key constraint as well * fixes * more sqlite --- homeassistant/components/recorder/core.py | 2 ++ homeassistant/components/recorder/migration.py | 17 +++++++++++------ homeassistant/components/recorder/purge.py | 17 ++++++++++------- tests/components/recorder/test_purge.py | 14 +++++++++++++- tests/components/recorder/test_v32_migration.py | 8 +++++++- 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 30dd311c0e62..da207e24cb46 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -221,6 +221,7 @@ class Recorder(threading.Thread): self.async_migration_event = asyncio.Event() self.migration_in_progress = False self.migration_is_live = False + self.use_legacy_events_index = False self._database_lock_task: DatabaseLockTask | None = None self._db_executor: DBInterruptibleThreadPoolExecutor | None = None @@ -744,6 +745,7 @@ class Recorder(threading.Thread): session, TABLE_STATES, LEGACY_STATES_EVENT_ID_INDEX ): self.queue_task(EventIdMigrationTask()) + self.use_legacy_events_index = True # We must only set the db ready after we have set the table managers # to active if there is no data to migrate. diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4619c4531d0c..a4dea027d8cb 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -513,11 +513,7 @@ def _drop_foreign_key_constraints( inspector = sqlalchemy.inspect(engine) drops = [] for foreign_key in inspector.get_foreign_keys(table): - if ( - foreign_key["name"] - and foreign_key.get("options", {}).get("ondelete") - and foreign_key["constrained_columns"] == columns - ): + if foreign_key["name"] and foreign_key["constrained_columns"] == columns: drops.append(ForeignKeyConstraint((), (), name=foreign_key["name"])) # Bind the ForeignKeyConstraints to the table @@ -1547,7 +1543,16 @@ def cleanup_legacy_states_event_ids(instance: Recorder) -> bool: if all_gone: # Only drop the index if there are no more event_ids in the states table # ex all NULL - _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) + assert instance.engine is not None, "engine should never be None" + if instance.dialect_name != SupportedDialect.SQLITE: + # SQLite does not support dropping foreign key constraints + # so we can't drop the index at this time but we can avoid + # looking for legacy rows during purge + _drop_foreign_key_constraints( + session_maker, instance.engine, TABLE_STATES, ["event_id"] + ) + _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) + instance.use_legacy_events_index = False return True diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 528cb1247fd5..fafb7c661a93 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -8,7 +8,6 @@ import logging import time from typing import TYPE_CHECKING -from sqlalchemy.engine.row import Row from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util @@ -74,7 +73,7 @@ def purge_old_data( with session_scope(session=instance.get_session()) as session: # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states or events record has_more_to_purge = False - if _purging_legacy_format(session): + if instance.use_legacy_events_index and _purging_legacy_format(session): _LOGGER.debug( "Purge running in legacy format as there are states with event_id" " remaining" @@ -671,14 +670,18 @@ def _purge_filtered_events( _LOGGER.debug( "Selected %s event_ids to remove that should be filtered", len(event_ids_set) ) - states: list[Row[tuple[int]]] = ( - session.query(States.state_id).filter(States.event_id.in_(event_ids_set)).all() - ) - if states: + if ( + instance.use_legacy_events_index + and ( + states := session.query(States.state_id) + .filter(States.event_id.in_(event_ids_set)) + .all() + ) + and (state_ids := {state.state_id for state in states}) + ): # These are legacy states that are linked to an event that are no longer # created but since we did not remove them when we stopped adding new ones # we will need to purge them here. - state_ids: set[int] = {state.state_id for state in states} _purge_state_ids(instance, session, state_ids) _purge_event_ids(session, event_ids_set) if unused_data_ids_set := _select_unused_event_data_ids( diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index f268325b2176..60620f39d694 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -10,7 +10,7 @@ from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder import Recorder, migration from homeassistant.components.recorder.const import ( SQLITE_MAX_BIND_VARS, SupportedDialect, @@ -1726,6 +1726,18 @@ async def test_purge_can_mix_legacy_and_new_format( ) -> None: """Test purging with legacy a new events.""" instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + # New databases are no longer created with the legacy events index + assert instance.use_legacy_events_index is False + + def _recreate_legacy_events_index(): + """Recreate the legacy events index since its no longer created on new instances.""" + migration._create_index(instance.get_session, "states", "ix_states_event_id") + instance.use_legacy_events_index = True + + await instance.async_add_executor_job(_recreate_legacy_events_index) + assert instance.use_legacy_events_index is True + utcnow = dt_util.utcnow() eleven_days_ago = utcnow - timedelta(days=11) with session_scope(hass=hass) as session: diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index dd49d7b21e1e..0b5389ddf7fb 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -142,6 +142,7 @@ async def test_migrate_times( _get_states_index_names ) states_index_names = {index["name"] for index in states_indexes} + assert recorder.get_instance(hass).use_legacy_events_index is True await hass.async_stop() await hass.async_block_till_done() @@ -212,7 +213,12 @@ async def test_migrate_times( ) states_index_names = {index["name"] for index in states_indexes} - assert "ix_states_event_id" not in states_index_names + # sqlite does not support dropping foreign keys so the + # ix_states_event_id index is not dropped in this case + # but use_legacy_events_index is still False + assert "ix_states_event_id" in states_index_names + + assert recorder.get_instance(hass).use_legacy_events_index is False await hass.async_stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ From f27d73fc3461f8d3fb36a829504ed5ec6d0d253a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:05:07 -1000 Subject: [PATCH 0602/1058] Remove legacy event lookups from logbook (#89945) Events recorded with Home Assistant 2022.5.x or older will no longer display context information in the logbook --- .../components/logbook/queries/all.py | 16 +---------- .../components/logbook/queries/common.py | 28 ------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 5311d5a9d6e0..8c37bf22da92 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -12,12 +12,7 @@ from homeassistant.components.recorder.db_schema import ( States, ) -from .common import ( - apply_states_filters, - legacy_select_events_context_id, - select_events_without_states, - select_states, -) +from .common import apply_states_filters, select_events_without_states, select_states def all_stmt( @@ -33,9 +28,6 @@ def all_stmt( lambda: select_events_without_states(start_day, end_day, event_types) ) if context_id_bin is not None: - # Once all the old `state_changed` events - # are gone from the database remove the - # _legacy_select_events_context_id() stmt += lambda s: s.where(Events.context_id_bin == context_id_bin).union_all( _states_query_for_context_id( start_day, @@ -43,12 +35,6 @@ def all_stmt( # https://github.com/python/mypy/issues/2608 context_id_bin, # type:ignore[arg-type] ), - legacy_select_events_context_id( - start_day, - end_day, - # https://github.com/python/mypy/issues/2608 - context_id_bin, # type:ignore[arg-type] - ), ) else: if events_entity_filter is not None: diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index c63bb30eb6c8..08bf1b8ab9bf 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -166,34 +166,6 @@ def select_states() -> Select: ) -def legacy_select_events_context_id( - start_day: float, end_day: float, context_id_bin: bytes -) -> Select: - """Generate a legacy events context id select that also joins states.""" - # This can be removed once we no longer have event_ids in the states table - return ( - select( - *EVENT_COLUMNS, - literal(value=None, type_=sqlalchemy.String).label("shared_data"), - *STATE_COLUMNS, - NOT_CONTEXT_ONLY, - ) - .outerjoin(States, (Events.event_id == States.event_id)) - .where( - (States.last_updated_ts == States.last_changed_ts) - | States.last_changed_ts.is_(None) - ) - .where(_not_continuous_entity_matcher()) - .outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) - .outerjoin(StatesMeta, (States.metadata_id == StatesMeta.metadata_id)) - .outerjoin(EventTypes, (Events.event_type_id == EventTypes.event_type_id)) - .where((Events.time_fired_ts > start_day) & (Events.time_fired_ts < end_day)) - .where(Events.context_id_bin == context_id_bin) - ) - - def apply_states_filters(sel: Select, start_day: float, end_day: float) -> Select: """Filter states by time range. From 817ba972276623b11ac33a4294ff18b435367ace Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:05:39 -1000 Subject: [PATCH 0603/1058] Remove unneeded lambda_stmt in place add in statistics (#89943) We can generate this entire query in a single lambda_stmt so there is no need to add two which increases the size of the cache key --- .../components/recorder/statistics.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2f2deeeaeee5..34f9c57f95d4 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1969,24 +1969,24 @@ def _latest_short_term_statistics_stmt( metadata_ids: list[int], ) -> StatementLambdaElement: """Create the statement for finding the latest short term stat rows.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) - stmt += lambda s: s.join( - ( - most_recent_statistic_row := ( - select( - StatisticsShortTerm.metadata_id, - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - func.max(StatisticsShortTerm.start_ts).label("start_max"), - ) - .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - .group_by(StatisticsShortTerm.metadata_id) - ).subquery() - ), - (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) - & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), + return lambda_stmt( + lambda: select(*QUERY_STATISTICS_SHORT_TERM).join( + ( + most_recent_statistic_row := ( + select( + StatisticsShortTerm.metadata_id, + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(StatisticsShortTerm.start_ts).label("start_max"), + ) + .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) + .group_by(StatisticsShortTerm.metadata_id) + ).subquery() + ), + (StatisticsShortTerm.metadata_id == most_recent_statistic_row.c.metadata_id) + & (StatisticsShortTerm.start_ts == most_recent_statistic_row.c.start_max), + ) ) - return stmt def get_latest_short_term_statistics( From bf63e6cbd40baa287add85cfe9adb06df162a4ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:30:01 -1000 Subject: [PATCH 0604/1058] Set unique on StatesMeta and EventTypes database tables (#89971) Set unique on StatesMeta and EventTypes These should have been marked unique originally to prevent collision bugs from going unnoticed. These have not been to beta yet so this is not a breaking change --- homeassistant/components/recorder/db_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 4826453e4c8b..0bb0b846a3fe 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -350,7 +350,7 @@ class EventTypes(Base): __tablename__ = TABLE_EVENT_TYPES event_type_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) event_type: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_EVENT_EVENT_TYPE), index=True + String(MAX_LENGTH_EVENT_EVENT_TYPE), index=True, unique=True ) def __repr__(self) -> str: @@ -600,7 +600,7 @@ class StatesMeta(Base): __tablename__ = TABLE_STATES_META metadata_id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True) entity_id: Mapped[str | None] = mapped_column( - String(MAX_LENGTH_STATE_ENTITY_ID), index=True + String(MAX_LENGTH_STATE_ENTITY_ID), index=True, unique=True ) def __repr__(self) -> str: From affb48d27170b7ce856b54fec62d69da4c11f9c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 16:44:35 -1000 Subject: [PATCH 0605/1058] Avoid joining states_meta for statistics queries (#89941) --- .../components/recorder/history/modern.py | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index d6269d21b23a..d39abd2e9f34 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -142,8 +142,8 @@ def _ignore_domains_filter(query: Query) -> Query: def _significant_states_stmt( start_time: datetime, end_time: datetime | None, - entity_ids: list[str] | None, metadata_ids: list[int] | None, + metadata_ids_in_significant_domains: list[int], filters: Filters | None, significant_changes_only: bool, no_attributes: bool, @@ -153,17 +153,23 @@ def _significant_states_stmt( no_attributes, include_last_changed=not significant_changes_only ) join_states_meta = False - if ( - entity_ids - and len(entity_ids) == 1 - and significant_changes_only - and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS - ): + if metadata_ids and significant_changes_only: + # Since we are filtering on entity_id (metadata_id) we can avoid + # the join of the states_meta table since we already know which + # metadata_ids are in the significant domains. stmt += lambda q: q.filter( - (States.last_changed_ts == States.last_updated_ts) + States.metadata_id.in_(metadata_ids_in_significant_domains) + | (States.last_changed_ts == States.last_updated_ts) | States.last_changed_ts.is_(None) ) elif significant_changes_only: + # This is the case where we are not filtering on entity_id + # so we need to join the states_meta table to filter out + # the domains we do not care about. This query path was + # only used by the old history page to show all entities + # in the UI. The new history page filters on entity_id + # so this query path is not used anymore except for third + # party integrations that use the history API. stmt += lambda q: q.filter( or_( *[ @@ -235,6 +241,7 @@ def get_significant_states_with_session( """ metadata_ids: list[int] | None = None entity_id_to_metadata_id: dict[str, int | None] | None = None + metadata_ids_in_significant_domains: list[int] = [] if entity_ids: instance = recorder.get_instance(hass) if not ( @@ -243,11 +250,18 @@ def get_significant_states_with_session( ) ) or not (metadata_ids := extract_metadata_ids(entity_id_to_metadata_id)): return {} + if significant_changes_only: + metadata_ids_in_significant_domains = [ + metadata_id + for entity_id, metadata_id in entity_id_to_metadata_id.items() + if metadata_id is not None + and split_entity_id(entity_id)[0] in SIGNIFICANT_DOMAINS + ] stmt = _significant_states_stmt( start_time, end_time, - entity_ids, metadata_ids, + metadata_ids_in_significant_domains, filters, significant_changes_only, no_attributes, From c94b054d75f6af7c488cdfd9aacf0b56703e9179 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 17:33:21 -1000 Subject: [PATCH 0606/1058] Retain history when renaming an entity_id (#89963) Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/__init__.py | 4 +- homeassistant/components/recorder/core.py | 50 ++-- .../components/recorder/entity_registry.py | 71 +++++ .../components/recorder/statistics.py | 33 +-- .../recorder/table_managers/states_meta.py | 17 ++ homeassistant/components/recorder/tasks.py | 18 +- tests/components/recorder/common.py | 92 ++++++- .../recorder/test_entity_registry.py | 245 ++++++++++++++++++ tests/components/recorder/test_statistics.py | 78 +----- 9 files changed, 478 insertions(+), 130 deletions(-) create mode 100644 homeassistant/components/recorder/entity_registry.py create mode 100644 tests/components/recorder/test_entity_registry.py diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 385c12f37a45..2621db9cb700 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.integration_platform import ( from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from . import statistics, websocket_api +from . import entity_registry, websocket_api from .const import ( # noqa: F401 CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, @@ -163,8 +163,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: instance.async_register() instance.start() async_register_services(hass, instance) - statistics.async_setup(hass) websocket_api.async_setup(hass) + entity_registry.async_setup(hass) await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) return await instance.async_db_ready diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index da207e24cb46..893117f6e321 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -109,6 +109,7 @@ from .tasks import ( StatisticsTask, StopTask, SynchronizeTask, + UpdateStatesMetadataTask, UpdateStatisticsMetadataTask, WaitTask, ) @@ -548,6 +549,15 @@ class Recorder(threading.Thread): ) ) + @callback + def async_update_states_metadata( + self, + entity_id: str, + new_entity_id: str, + ) -> None: + """Update states metadata for an entity_id.""" + self.queue_task(UpdateStatesMetadataTask(entity_id, new_entity_id)) + @callback def async_change_statistics_unit( self, @@ -970,8 +980,26 @@ class Recorder(threading.Thread): def _process_state_changed_event_into_session(self, event: Event) -> None: """Process a state_changed event into the session.""" state_attributes_manager = self.state_attributes_manager + states_meta_manager = self.states_meta_manager + entity_removed = not event.data.get("new_state") + entity_id = event.data["entity_id"] + dbstate = States.from_event(event) - if (entity_id := dbstate.entity_id) is None or not ( + + states_manager = self.states_manager + if old_state := states_manager.pop_pending(entity_id): + dbstate.old_state = old_state + elif old_state_id := states_manager.pop_committed(entity_id): + dbstate.old_state_id = old_state_id + if entity_removed: + dbstate.state = None + else: + states_manager.add_pending(entity_id, dbstate) + + if states_meta_manager.active: + dbstate.entity_id = None + + if entity_id is None or not ( shared_attrs_bytes := state_attributes_manager.serialize_from_event(event) ): return @@ -979,11 +1007,16 @@ class Recorder(threading.Thread): assert self.event_session is not None session = self.event_session # Map the entity_id to the StatesMeta table - states_meta_manager = self.states_meta_manager if pending_states_meta := states_meta_manager.get_pending(entity_id): dbstate.states_meta_rel = pending_states_meta elif metadata_id := states_meta_manager.get(entity_id, session, True): dbstate.metadata_id = metadata_id + elif states_meta_manager.active and entity_removed: + # If the entity was removed, we don't need to add it to the + # StatesMeta table or record it in the pending commit + # if it does not have a metadata_id allocated to it as + # it either never existed or was just renamed. + return else: states_meta = StatesMeta(entity_id=entity_id) states_meta_manager.add_pending(states_meta) @@ -1015,19 +1048,6 @@ class Recorder(threading.Thread): session.add(dbstate_attributes) dbstate.state_attributes = dbstate_attributes - states_manager = self.states_manager - if old_state := states_manager.pop_pending(entity_id): - dbstate.old_state = old_state - elif old_state_id := states_manager.pop_committed(entity_id): - dbstate.old_state_id = old_state_id - if event.data.get("new_state"): - states_manager.add_pending(entity_id, dbstate) - else: - dbstate.state = None - - if states_meta_manager.active: - dbstate.entity_id = None - session.add(dbstate) def _handle_database_error(self, err: Exception) -> bool: diff --git a/homeassistant/components/recorder/entity_registry.py b/homeassistant/components/recorder/entity_registry.py new file mode 100644 index 000000000000..fbf6e6917770 --- /dev/null +++ b/homeassistant/components/recorder/entity_registry.py @@ -0,0 +1,71 @@ +"""Recorder entity registry helper.""" +import logging + +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.start import async_at_start + +from .core import Recorder +from .util import get_instance, session_scope + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the entity hooks.""" + + @callback + def _async_entity_id_changed(event: Event) -> None: + instance = get_instance(hass) + old_entity_id: str = event.data["old_entity_id"] + new_entity_id: str = event.data["entity_id"] + instance.async_update_statistics_metadata( + old_entity_id, new_statistic_id=new_entity_id + ) + instance.async_update_states_metadata( + old_entity_id, new_entity_id=new_entity_id + ) + + @callback + def entity_registry_changed_filter(event: Event) -> bool: + """Handle entity_id changed filter.""" + return event.data["action"] == "update" and "old_entity_id" in event.data + + @callback + def _setup_entity_registry_event_handler(hass: HomeAssistant) -> None: + """Subscribe to event registry events.""" + hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + _async_entity_id_changed, + event_filter=entity_registry_changed_filter, + run_immediately=True, + ) + + async_at_start(hass, _setup_entity_registry_event_handler) + + +def update_states_metadata( + instance: Recorder, + entity_id: str, + new_entity_id: str, +) -> None: + """Update the states metadata table when an entity is renamed.""" + states_meta_manager = instance.states_meta_manager + if not states_meta_manager.active: + _LOGGER.warning( + "Cannot rename entity_id `%s` to `%s` " + "because the states meta manager is not yet active", + entity_id, + new_entity_id, + ) + return + + with session_scope(session=instance.get_session()) as session: + if not states_meta_manager.update_metadata(session, entity_id, new_entity_id): + _LOGGER.warning( + "Cannot migrate history for entity_id `%s` to `%s` " + "because the new entity_id is already in use", + entity_id, + new_entity_id, + ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 34f9c57f95d4..2f93a8a833e8 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -26,11 +26,9 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT -from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id +from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.start import async_at_start from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util @@ -326,35 +324,6 @@ class ValidationIssue: return dataclasses.asdict(self) -def async_setup(hass: HomeAssistant) -> None: - """Set up the history hooks.""" - - @callback - def _async_entity_id_changed(event: Event) -> None: - get_instance(hass).async_update_statistics_metadata( - event.data["old_entity_id"], new_statistic_id=event.data["entity_id"] - ) - - @callback - def entity_registry_changed_filter(event: Event) -> bool: - """Handle entity_id changed filter.""" - if event.data["action"] != "update" or "old_entity_id" not in event.data: - return False - - return True - - @callback - def setup_entity_registry_event_handler(hass: HomeAssistant) -> None: - """Subscribe to event registry events.""" - hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - _async_entity_id_changed, - event_filter=entity_registry_changed_filter, - ) - - async_at_start(hass, setup_entity_registry_event_handler) - - def get_start_time() -> datetime: """Return start time.""" now = dt_util.utcnow() diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index c9c1ba902809..639e0acaa3a7 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -144,3 +144,20 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): """ for entity_id in entity_ids: self._id_map.pop(entity_id, None) + + def update_metadata( + self, + session: Session, + entity_id: str, + new_entity_id: str, + ) -> bool: + """Update states metadata for an entity_id.""" + if self.get(new_entity_id, session, True) is not None: + # If the new entity id already exists we have + # a collision and should not update. + return False + session.query(StatesMeta).filter(StatesMeta.entity_id == entity_id).update( + {StatesMeta.entity_id: new_entity_id} + ) + self._id_map.pop(entity_id, None) + return True diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index ef8f6a95a7cb..d3e3c053825f 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.core import Event from homeassistant.helpers.typing import UndefinedType -from . import purge, statistics +from . import entity_registry, purge, statistics from .const import DOMAIN, EXCLUDE_ATTRIBUTES from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData @@ -83,6 +83,22 @@ class UpdateStatisticsMetadataTask(RecorderTask): ) +@dataclass +class UpdateStatesMetadataTask(RecorderTask): + """Task to update states metadata.""" + + entity_id: str + new_entity_id: str + + def run(self, instance: Recorder) -> None: + """Handle the task.""" + entity_registry.update_states_metadata( + instance, + self.entity_id, + self.new_entity_id, + ) + + @dataclass class PurgeTask(RecorderTask): """Object to store information about purge task.""" diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 17e8c47f6b40..0da58cce6c59 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -4,21 +4,22 @@ from __future__ import annotations import asyncio from collections.abc import Iterable from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta import time from typing import Any, Literal, cast +from unittest.mock import patch, sentinel from sqlalchemy import create_engine from sqlalchemy.orm.session import Session from homeassistant import core as ha from homeassistant.components import recorder -from homeassistant.components.recorder import get_instance, statistics -from homeassistant.components.recorder.core import Recorder +from homeassistant.components.recorder import Recorder, get_instance, statistics from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask +from homeassistant.const import UnitOfTemperature from homeassistant.core import Event, HomeAssistant, State -from homeassistant.util import dt as dt_util +import homeassistant.util.dt as dt_util from . import db_schema_0 @@ -38,6 +39,15 @@ class BlockRecorderTask(RecorderTask): time.sleep(self.seconds) +@dataclass +class ForceReturnConnectionToPool(RecorderTask): + """Force return connection to pool.""" + + def run(self, instance: Recorder) -> None: + """Handle the task.""" + instance.event_session.commit() + + async def async_block_recorder(hass: HomeAssistant, seconds: float) -> None: """Block the recorders event loop for testing. @@ -223,3 +233,77 @@ def assert_dict_of_states_equal_without_context_and_last_changed( assert_multiple_states_equal_without_context_and_last_changed( state, others[entity_id] ) + + +def record_states(hass): + """Record some test states. + + We inject a bunch of state updates temperature sensors. + """ + mp = "media_player.test" + sns1 = "sensor.test1" + sns2 = "sensor.test2" + sns3 = "sensor.test3" + sns4 = "sensor.test4" + sns1_attr = { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": UnitOfTemperature.CELSIUS, + } + sns2_attr = { + "device_class": "humidity", + "state_class": "measurement", + "unit_of_measurement": "%", + } + sns3_attr = {"device_class": "temperature"} + sns4_attr = {} + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + zero = dt_util.utcnow() + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=15 * 5) + three = two + timedelta(seconds=30 * 5) + four = three + timedelta(seconds=15 * 5) + + states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one + ): + states[mp].append( + set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) + ) + states[sns1].append(set_state(sns1, "10", attributes=sns1_attr)) + states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) + states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) + states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", + return_value=one + timedelta(microseconds=1), + ): + states[mp].append( + set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) + ) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two + ): + states[sns1].append(set_state(sns1, "15", attributes=sns1_attr)) + states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) + states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) + states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three + ): + states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) + states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) + states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) + states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) + + return zero, four, states diff --git a/tests/components/recorder/test_entity_registry.py b/tests/components/recorder/test_entity_registry.py new file mode 100644 index 000000000000..922032539c4d --- /dev/null +++ b/tests/components/recorder/test_entity_registry.py @@ -0,0 +1,245 @@ +"""The tests for sensor recorder platform.""" +from collections.abc import Callable + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session + +from homeassistant.components.recorder import history +from homeassistant.components.recorder.db_schema import StatesMeta +from homeassistant.components.recorder.util import session_scope +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import setup_component +from homeassistant.util import dt as dt_util + +from .common import ( + ForceReturnConnectionToPool, + assert_dict_of_states_equal_without_context_and_last_changed, + async_wait_recording_done, + record_states, + wait_recording_done, +) + +from tests.common import MockEntity, MockEntityPlatform, mock_registry +from tests.typing import RecorderInstanceGenerator + + +def _count_entity_id_in_states_meta( + hass: HomeAssistant, session: Session, entity_id: str +) -> int: + return len( + list( + session.execute( + select(StatesMeta).filter(StatesMeta.entity_id == "sensor.test99") + ) + ) + ) + + +def test_rename_entity_without_collision( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test states meta is migrated when entity_id is changed.""" + hass = hass_recorder() + setup_component(hass, "sensor", {}) + + entity_reg = mock_registry(hass) + + @callback + def add_entry(): + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + hass.add_job(add_entry) + hass.block_till_done() + + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four) + + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + @callback + def rename_entry(): + entity_reg.async_update_entity("sensor.test1", new_entity_id="sensor.test99") + + hass.add_job(rename_entry) + wait_recording_done(hass) + + hist = history.get_significant_states(hass, zero, four) + states["sensor.test99"] = states.pop("sensor.test1") + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + hass.states.set("sensor.test99", "post_migrate") + wait_recording_done(hass) + new_hist = history.get_significant_states(hass, zero, dt_util.utcnow()) + assert not new_hist.get("sensor.test1") + assert new_hist["sensor.test99"][-1].state == "post_migrate" + + with session_scope(hass=hass) as session: + assert _count_entity_id_in_states_meta(hass, session, "sensor.test99") == 1 + assert _count_entity_id_in_states_meta(hass, session, "sensor.test1") == 1 + + assert "the new entity_id is already in use" not in caplog.text + + +async def test_rename_entity_on_mocked_platform( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test states meta is migrated when entity_id is changed when using a mocked platform. + + This test will call async_remove on the entity so we can make + sure that we do not record the entity as removed in the database + when we rename it. + """ + instance = await async_setup_recorder_instance(hass) + entity_reg = er.async_get(hass) + start = dt_util.utcnow() + + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + entity_platform1 = MockEntityPlatform( + hass, domain="mock_integration", platform_name="mock_platform", platform=None + ) + entity1 = MockEntity(entity_id=reg_entry.entity_id) + await entity_platform1.async_add_entities([entity1]) + + await hass.async_block_till_done() + + hass.states.async_set("sensor.test1", "pre_migrate") + await async_wait_recording_done(hass) + + hist = await instance.async_add_executor_job( + history.get_significant_states, + hass, + start, + None, + ["sensor.test1", "sensor.test99"], + ) + + entity_reg.async_update_entity("sensor.test1", new_entity_id="sensor.test99") + await hass.async_block_till_done() + # We have to call the remove method ourselves since we are mocking the platform + hass.states.async_remove("sensor.test1") + + # The remove will trigger a lookup of the non-existing entity_id in the database + # so we need to force the recorder to return the connection to the pool + # since our test setup only allows one connection at a time. + instance.queue_task(ForceReturnConnectionToPool()) + + await async_wait_recording_done(hass) + + hist = await instance.async_add_executor_job( + history.get_significant_states, + hass, + start, + None, + ["sensor.test1", "sensor.test99"], + ) + + assert "sensor.test1" not in hist + # Make sure the states manager has not leaked the old entity_id + assert instance.states_manager.pop_committed("sensor.test1") is None + assert instance.states_manager.pop_pending("sensor.test1") is None + + hass.states.async_set("sensor.test99", "post_migrate") + await async_wait_recording_done(hass) + + new_hist = await instance.async_add_executor_job( + history.get_significant_states, + hass, + start, + None, + ["sensor.test1", "sensor.test99"], + ) + + assert "sensor.test1" not in new_hist + assert new_hist["sensor.test99"][-1].state == "post_migrate" + + def _get_states_meta_counts(): + with session_scope(hass=hass) as session: + return _count_entity_id_in_states_meta( + hass, session, "sensor.test99" + ), _count_entity_id_in_states_meta(hass, session, "sensor.test1") + + test99_count, test1_count = await instance.async_add_executor_job( + _get_states_meta_counts + ) + assert test99_count == 1 + assert test1_count == 1 + + assert "the new entity_id is already in use" not in caplog.text + + +def test_rename_entity_collision( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test states meta is not migrated when there is a collision.""" + hass = hass_recorder() + setup_component(hass, "sensor", {}) + + entity_reg = mock_registry(hass) + + @callback + def add_entry(): + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + hass.add_job(add_entry) + hass.block_till_done() + + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + assert len(hist["sensor.test1"]) == 3 + + hass.states.set("sensor.test99", "collision") + hass.states.remove("sensor.test99") + + hass.block_till_done() + + # Rename entity sensor.test1 to sensor.test99 + @callback + def rename_entry(): + entity_reg.async_update_entity("sensor.test1", new_entity_id="sensor.test99") + + hass.add_job(rename_entry) + wait_recording_done(hass) + + # History is not migrated on collision + hist = history.get_significant_states(hass, zero, four) + assert len(hist["sensor.test1"]) == 3 + assert len(hist["sensor.test99"]) == 2 + + with session_scope(hass=hass) as session: + assert _count_entity_id_in_states_meta(hass, session, "sensor.test99") == 1 + + hass.states.set("sensor.test99", "post_migrate") + wait_recording_done(hass) + new_hist = history.get_significant_states(hass, zero, dt_util.utcnow()) + assert new_hist["sensor.test99"][-1].state == "post_migrate" + assert len(hist["sensor.test99"]) == 2 + + with session_scope(hass=hass) as session: + assert _count_entity_id_in_states_meta(hass, session, "sensor.test99") == 1 + assert _count_entity_id_in_states_meta(hass, session, "sensor.test1") == 1 + + assert "the new entity_id is already in use" in caplog.text diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index de75052d3449..4863c6c05475 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import datetime, timedelta import importlib import sys -from unittest.mock import ANY, DEFAULT, MagicMock, patch, sentinel +from unittest.mock import ANY, DEFAULT, MagicMock, patch import py import pytest @@ -43,7 +43,6 @@ from homeassistant.components.recorder.table_managers.statistics_meta import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import UNIT_CONVERTERS -from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import recorder as recorder_helper @@ -54,6 +53,7 @@ from .common import ( assert_dict_of_states_equal_without_context_and_last_changed, async_wait_recording_done, do_adhoc_statistics, + record_states, statistics_during_period, wait_recording_done, ) @@ -1758,80 +1758,6 @@ async def test_validate_db_schema_fix_statistics_datetime_issue( modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) -def record_states(hass): - """Record some test states. - - We inject a bunch of state updates temperature sensors. - """ - mp = "media_player.test" - sns1 = "sensor.test1" - sns2 = "sensor.test2" - sns3 = "sensor.test3" - sns4 = "sensor.test4" - sns1_attr = { - "device_class": "temperature", - "state_class": "measurement", - "unit_of_measurement": UnitOfTemperature.CELSIUS, - } - sns2_attr = { - "device_class": "humidity", - "state_class": "measurement", - "unit_of_measurement": "%", - } - sns3_attr = {"device_class": "temperature"} - sns4_attr = {} - - def set_state(entity_id, state, **kwargs): - """Set the state.""" - hass.states.set(entity_id, state, **kwargs) - wait_recording_done(hass) - return hass.states.get(entity_id) - - zero = dt_util.utcnow() - one = zero + timedelta(seconds=1 * 5) - two = one + timedelta(seconds=15 * 5) - three = two + timedelta(seconds=30 * 5) - four = three + timedelta(seconds=15 * 5) - - states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): - states[mp].append( - set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[sns1].append(set_state(sns1, "10", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) - states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) - - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): - states[mp].append( - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): - states[sns1].append(set_state(sns1, "15", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) - states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) - - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): - states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) - states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) - - return zero, four, states - - def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) From 939fce4607f51b144ff6568096dbd6571272684b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 20 Mar 2023 04:35:16 +0100 Subject: [PATCH 0607/1058] Shield Reolink webhook callback from cancelation (#89798) * shield Reolink webhook callback from cancelation * Update homeassistant/components/reolink/host.py Co-authored-by: Paulus Schoutsen * fix styling * fix black * Revert to using asyncio.shield --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/reolink/host.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index e8e96ffe9b99..9ba4809e90d5 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -327,6 +327,12 @@ class ReolinkHost: async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request + ): + """Shield the incoming webhook callback from cancellation.""" + await asyncio.shield(self.handle_webhook_shielded(hass, webhook_id, request)) + + async def handle_webhook_shielded( + self, hass: HomeAssistant, webhook_id: str, request: Request ): """Handle incoming webhook from Reolink for inbound messages and calls.""" From 2039955ef7cd4326767942047047b99144ec2f00 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Mar 2023 04:35:45 +0100 Subject: [PATCH 0608/1058] Fix imap_email_content unknown status and replaying stale states (#89563) --- .../components/imap_email_content/sensor.py | 74 ++++++++++++------- .../imap_email_content/test_sensor.py | 26 +++++-- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index b14de632687b..53cb921860c7 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -95,9 +95,25 @@ class EmailReader: self._folder = folder self._verify_ssl = verify_ssl self._last_id = None + self._last_message = None self._unread_ids = deque([]) self.connection = None + @property + def last_id(self) -> int | None: + """Return last email uid that was processed.""" + return self._last_id + + @property + def last_unread_id(self) -> int | None: + """Return last email uid received.""" + # We assume the last id in the list is the last unread id + # We cannot know if that is the newest one, because it could arrive later + # https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids + if self._unread_ids: + return int(self._unread_ids[-1]) + return self._last_id + def connect(self): """Login and setup the connection.""" ssl_context = client_context() if self._verify_ssl else None @@ -128,21 +144,21 @@ class EmailReader: try: self.connection.select(self._folder, readonly=True) - if not self._unread_ids: - search = f"SINCE {datetime.date.today():%d-%b-%Y}" - if self._last_id is not None: - search = f"UID {self._last_id}:*" - - _, data = self.connection.uid("search", None, search) - self._unread_ids = deque(data[0].split()) + if self._last_id is None: + # search for today and yesterday + time_from = datetime.datetime.now() - datetime.timedelta(days=1) + search = f"SINCE {time_from:%d-%b-%Y}" + else: + search = f"UID {self._last_id}:*" + _, data = self.connection.uid("search", None, search) + self._unread_ids = deque(data[0].split()) while self._unread_ids: message_uid = self._unread_ids.popleft() if self._last_id is None or int(message_uid) > self._last_id: self._last_id = int(message_uid) - return self._fetch_message(message_uid) - - return self._fetch_message(str(self._last_id)) + self._last_message = self._fetch_message(message_uid) + return self._last_message except imaplib.IMAP4.error: _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) @@ -254,22 +270,30 @@ class EmailContentSensor(SensorEntity): def update(self) -> None: """Read emails and publish state change.""" email_message = self._email_reader.read_next() + while ( + self._last_id is None or self._last_id != self._email_reader.last_unread_id + ): + if email_message is None: + self._message = None + self._state_attributes = {} + return - if email_message is None: - self._message = None - self._state_attributes = {} - return + self._last_id = self._email_reader.last_id - if self.sender_allowed(email_message): - message = EmailContentSensor.get_msg_subject(email_message) + if self.sender_allowed(email_message): + message = EmailContentSensor.get_msg_subject(email_message) - if self._value_template is not None: - message = self.render_template(email_message) + if self._value_template is not None: + message = self.render_template(email_message) - self._message = message - self._state_attributes = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } + self._message = message + self._state_attributes = { + ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), + ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), + ATTR_DATE: email_message["Date"], + ATTR_BODY: EmailContentSensor.get_msg_text(email_message), + } + + if self._last_id == self._email_reader.last_unread_id: + break + email_message = self._email_reader.read_next() diff --git a/tests/components/imap_email_content/test_sensor.py b/tests/components/imap_email_content/test_sensor.py index afa6116ff426..ba2b362af736 100644 --- a/tests/components/imap_email_content/test_sensor.py +++ b/tests/components/imap_email_content/test_sensor.py @@ -14,9 +14,16 @@ from homeassistant.helpers.template import Template class FakeEMailReader: """A test class for sending test emails.""" - def __init__(self, messages): + def __init__(self, messages) -> None: """Set up the fake email reader.""" self._messages = messages + self.last_id = 0 + self.last_unread_id = len(messages) + + def add_test_message(self, message): + """Add a new message.""" + self.last_unread_id += 1 + self._messages.append(message) def connect(self): """Stay always Connected.""" @@ -26,6 +33,7 @@ class FakeEMailReader: """Get the next email.""" if len(self._messages) == 0: return None + self.last_id += 1 return self._messages.popleft() @@ -146,7 +154,7 @@ async def test_multi_part_only_other_text(hass: HomeAssistant) -> None: async def test_multiple_emails(hass: HomeAssistant) -> None: - """Test multiple emails.""" + """Test multiple emails, discarding stale states.""" states = [] test_message1 = email.message.Message() @@ -158,9 +166,15 @@ async def test_multiple_emails(hass: HomeAssistant) -> None: test_message2 = email.message.Message() test_message2["From"] = "sender@test.com" test_message2["Subject"] = "Test 2" - test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) + test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 58) test_message2.set_payload("Test Message 2") + test_message3 = email.message.Message() + test_message3["From"] = "sender@test.com" + test_message3["Subject"] = "Test 3" + test_message3["Date"] = datetime.datetime(2016, 1, 1, 12, 50, 1) + test_message3.set_payload("Test Message 2") + def state_changed_listener(entity_id, from_s, to_s): states.append(to_s) @@ -178,11 +192,13 @@ async def test_multiple_emails(hass: HomeAssistant) -> None: sensor.async_schedule_update_ha_state(True) await hass.async_block_till_done() + # Fake a new received message + sensor._email_reader.add_test_message(test_message3) sensor.async_schedule_update_ha_state(True) await hass.async_block_till_done() - assert states[0].state == "Test" - assert states[1].state == "Test 2" + assert states[0].state == "Test 2" + assert states[1].state == "Test 3" assert sensor.extra_state_attributes["body"] == "Test Message 2" From 9721ba59b6ae2f549d75f633b4c50855659cdb47 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 19 Mar 2023 20:42:12 -0700 Subject: [PATCH 0609/1058] Rewrite the calendar trigger to fix potential bugs (#89918) Update the calander event trigger logic to have more exhaustive coverage. The trigger will now use a timespan to create an explicit window for considering upcoming events. The start/end of the time span is now more explicit, rather than getting it from the alarm time. The trigger is now broken into composable pieces: - A timespan object for more explicitly managing the time window - A function to get events during a time span - A function to process upcoming events and determine the trigger times The existing listener is now just responsible for scheduling alarms and glue. This fixes bug with DST handling where the conversion back and forth between UTC and timezone ends up dropping events during the jump forward. In practice, an event was returned from the scanning, but it was never fired by the trigger because (1) it was filtered out of the interval and (2) the event list was previously cleared every iteration so it would get dropped. Future improvements can bake more invariant checking into this structure. --- homeassistant/components/calendar/trigger.py | 197 +++++++++++++------ tests/components/calendar/test_trigger.py | 66 ++++++- 2 files changed, 196 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 1e51c746e183..7807539413b0 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -1,7 +1,8 @@ """Offer calendar automation rules.""" from __future__ import annotations -from collections.abc import Coroutine +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass import datetime import logging from typing import Any @@ -14,7 +15,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import ( - async_track_point_in_utc_time, + async_track_point_in_time, async_track_time_interval, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo @@ -41,34 +42,135 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( # mypy: disallow-any-generics +@dataclass +class QueuedCalendarEvent: + """An event that is queued to be fired in the future.""" + + trigger_time: datetime.datetime + event: CalendarEvent + + +@dataclass +class Timespan: + """A time range part of start/end dates, used for considering active events.""" + + start: datetime.datetime + """The start datetime of the interval.""" + + end: datetime.datetime + """The end datetime (exclusive) of the interval.""" + + def with_offset(self, offset: datetime.timedelta) -> Timespan: + """Return a new interval shifted by the specified offset.""" + return Timespan(self.start + offset, self.end + offset) + + def contains(self, trigger: datetime.datetime) -> bool: + """Return true if the trigger time is within the time span.""" + return self.start <= trigger < self.end + + def next_upcoming( + self, now: datetime.datetime, interval: datetime.timedelta + ) -> Timespan: + """Return a subsequent time span following the current time span. + + This effectively gives us a cursor like interface for advancing through + time using the interval as a hint. The returned span may have a + different interval than the one specified. For example, time span may + be longer during a daylight saving time transition, or may extend due to + drift if the current interval is old. The returned time span is + adjacent and non-overlapping. + """ + return Timespan(self.end, max(self.end, now) + interval) + + def __str__(self) -> str: + """Return a compact string representation.""" + return f"[{self.start}, {self.end})" + + +EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]] +QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]] + + +def event_fetcher(hass: HomeAssistant, entity: CalendarEntity) -> EventFetcher: + """Build an async_get_events wrapper to fetch events during a time span.""" + + async def async_get_events(timespan: Timespan) -> list[CalendarEvent]: + """Return events active in the specified time span.""" + # Expand by one second to make the end time exclusive + end_time = timespan.end + datetime.timedelta(seconds=1) + return await entity.async_get_events(hass, timespan.start, end_time) + + return async_get_events + + +def queued_event_fetcher( + fetcher: EventFetcher, event_type: str, offset: datetime.timedelta +) -> QueuedEventFetcher: + """Build a fetcher that produces a schedule of upcoming trigger events.""" + + def get_trigger_time(event: CalendarEvent) -> datetime.datetime: + if event_type == EVENT_START: + return event.start_datetime_local + return event.end_datetime_local + + async def async_get_events(timespan: Timespan) -> list[QueuedCalendarEvent]: + """Get calendar event triggers eligible to fire in the time span.""" + offset_timespan = timespan.with_offset(-1 * offset) + active_events = await fetcher(offset_timespan) + + # Determine the trigger eligibilty of events during this time span. + # Example: For an EVENT_END trigger the event may start during this + # time span, but need to be triggered later when the end happens. + results = [] + for trigger_time, event in zip( + map(get_trigger_time, active_events), active_events + ): + if not offset_timespan.contains(trigger_time): + continue + results.append(QueuedCalendarEvent(trigger_time + offset, event)) + _LOGGER.debug( + "Scan events @ %s%s found %s eligble of %s active", + offset_timespan, + f" (offset={offset})" if offset else "", + len(results), + len(active_events), + ) + results.sort(key=lambda x: x.trigger_time) + return results + + return async_get_events + + class CalendarEventListener: - """Helper class to listen to calendar events.""" + """Helper class to listen to calendar events. + + This listener will poll every UPDATE_INTERVAL to fetch a set of upcoming + calendar events in the upcoming window of time, putting them into a queue. + The queue is drained by scheduling an alarm for the next upcoming event + trigger time, one event at a time. + """ def __init__( self, hass: HomeAssistant, job: HassJob[..., Coroutine[Any, Any, None]], trigger_data: dict[str, Any], - entity: CalendarEntity, - event_type: str, - offset: datetime.timedelta, + fetcher: QueuedEventFetcher, ) -> None: """Initialize CalendarEventListener.""" self._hass = hass self._job = job self._trigger_data = trigger_data - self._entity = entity - self._offset = offset self._unsub_event: CALLBACK_TYPE | None = None self._unsub_refresh: CALLBACK_TYPE | None = None - # Upcoming set of events with their trigger time - self._events: list[tuple[datetime.datetime, CalendarEvent]] = [] - self._event_type = event_type + self._fetcher = fetcher + now = dt_util.now() + self._timespan = Timespan(now, now + UPDATE_INTERVAL) + self._events: list[QueuedCalendarEvent] = [] async def async_attach(self) -> None: """Attach a calendar event listener.""" - now = dt_util.utcnow() - await self._fetch_events(now) + self._events.extend(await self._fetcher(self._timespan)) self._unsub_refresh = async_track_time_interval( self._hass, self._handle_refresh, UPDATE_INTERVAL ) @@ -82,52 +184,19 @@ class CalendarEventListener: self._unsub_refresh() self._unsub_refresh = None - async def _fetch_events(self, last_endtime: datetime.datetime) -> None: - """Update the set of eligible events.""" - # Use a sliding window for selecting in scope events in the next interval. - # The event search range is offset, then the fire time of the returned events - # are offset again below. Event time ranges are exclusive so the end time - # is expanded by 1sec. - start_time = last_endtime - self._offset - end_time = start_time + UPDATE_INTERVAL + datetime.timedelta(seconds=1) - _LOGGER.debug( - "Fetching events between %s, %s (offset=%s)", - start_time, - end_time, - self._offset, - ) - events = await self._entity.async_get_events(self._hass, start_time, end_time) - - # Build list of events and the appropriate time to trigger an alarm. The - # returned events may have already started but matched the start/end time - # filtering above, so exclude any events that have already passed the - # trigger time. - event_list = [] - for event in events: - event_fire_time = ( - event.start_datetime_local - if self._event_type == EVENT_START - else event.end_datetime_local - ) - event_fire_time += self._offset - if event_fire_time > last_endtime: - event_list.append((event_fire_time, event)) - event_list.sort(key=lambda x: x[0]) - self._events = event_list - _LOGGER.debug("Populated event list %s", self._events) - @callback def _listen_next_calendar_event(self) -> None: """Set up the calendar event listener.""" if not self._events: return - (event_fire_time, _event) = self._events[0] - _LOGGER.debug("Scheduled alarm for %s", event_fire_time) - self._unsub_event = async_track_point_in_utc_time( + _LOGGER.debug( + "Scheduled next event trigger for %s", self._events[0].trigger_time + ) + self._unsub_event = async_track_point_in_time( self._hass, self._handle_calendar_event, - event_fire_time, + self._events[0].trigger_time, ) def _clear_event_listener(self) -> None: @@ -138,29 +207,36 @@ class CalendarEventListener: async def _handle_calendar_event(self, now: datetime.datetime) -> None: """Handle calendar event.""" - _LOGGER.debug("Calendar event @ %s", now) + _LOGGER.debug("Calendar event @ %s", dt_util.as_local(now)) self._dispatch_events(now) self._clear_event_listener() self._listen_next_calendar_event() def _dispatch_events(self, now: datetime.datetime) -> None: """Dispatch all events that are eligible to fire.""" - while self._events and self._events[0][0] <= now: - (_fire_time, event) = self._events.pop(0) - _LOGGER.debug("Event: %s", event) + while self._events and self._events[0].trigger_time <= now: + queued_event = self._events.pop(0) + _LOGGER.debug("Dispatching event: %s", queued_event.event) self._hass.async_run_hass_job( self._job, - {"trigger": {**self._trigger_data, "calendar_event": event.as_dict()}}, + { + "trigger": { + **self._trigger_data, + "calendar_event": queued_event.event.as_dict(), + } + }, ) - async def _handle_refresh(self, now: datetime.datetime) -> None: + async def _handle_refresh(self, now_utc: datetime.datetime) -> None: """Handle core config update.""" + now = dt_util.as_local(now_utc) _LOGGER.debug("Refresh events @ %s", now) # Dispatch any eligible events in the boundary case where refresh # fires before the calendar event. self._dispatch_events(now) self._clear_event_listener() - await self._fetch_events(now) + self._timespan = self._timespan.next_upcoming(now, UPDATE_INTERVAL) + self._events.extend(await self._fetcher(self._timespan)) self._listen_next_calendar_event() @@ -190,7 +266,10 @@ async def async_attach_trigger( "offset": offset, } listener = CalendarEventListener( - hass, HassJob(action), trigger_data, entity, event_type, offset + hass, + HassJob(action), + trigger_data, + queued_event_fetcher(event_fetcher(hass, entity), event_type, offset), ) await listener.async_attach() return listener.async_detach diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 7885a4524cf1..9e15a1996dcc 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -14,6 +14,7 @@ import logging import secrets from typing import Any from unittest.mock import patch +import zoneinfo from freezegun.api import FrozenDateTimeFactory import pytest @@ -87,19 +88,17 @@ class FakeSchedule: """Get all events in a specific time frame, used by the demo calendar.""" assert start_date < end_date values = [] - local_start_date = dt_util.as_local(start_date) - local_end_date = dt_util.as_local(end_date) for event in self.events: - if ( - event.start_datetime_local < local_end_date - and local_start_date < event.end_datetime_local - ): - values.append(event) + 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.""" - _LOGGER.debug(f"Firing alarm @ {trigger_time}") + _LOGGER.debug(f"Firing alarm @ {dt_util.as_local(trigger_time)}") self.freezer.move_to(trigger_time) async_fire_time_changed(self.hass, trigger_time) await self.hass.async_block_till_done() @@ -666,3 +665,54 @@ async def test_trigger_timestamp_window_edge( "calendar_event": event_data, } ] + + +async def test_event_start_trigger_dst( + hass: HomeAssistant, calls, fake_schedule, freezer +) -> None: + """Test a calendar event trigger happening at the start of daylight savings time.""" + tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles") + hass.config.set_time_zone("America/Los_Angeles") + freezer.move_to("2023-03-12 01:00:00-08:00") + + # Before DST transition starts + event1_data = fake_schedule.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( + 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( + summary="Event 3", + start=datetime.datetime(2023, 3, 12, 3, 30, tzinfo=tzinfo), + end=datetime.datetime(2023, 3, 12, 3, 45, tzinfo=tzinfo), + ) + await create_automation(hass, EVENT_START) + assert len(calls()) == 0 + + await fake_schedule.fire_until( + datetime.datetime.fromisoformat("2023-03-12 05:00:00-08:00"), + ) + assert calls() == [ + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event1_data, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event2_data, + }, + { + "platform": "calendar", + "event": EVENT_START, + "calendar_event": event3_data, + }, + ] From e798c30b8b2332361951742dc4df12cc355ae596 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 18:06:23 -1000 Subject: [PATCH 0610/1058] Fix statistics schema auto repair when there is bad data (#89903) - If the user had previously duplicated data we could end up picking the next metadata_id and there could be stale rows in the database that have that metadata_id. This can only happen from bad manual migrations (which is what this is function is validating in the first place). To solve this we now insert data with a future date and look at the latest inserted row instead of the first. Example ``` ['stored_statistics', defaultdict(, {'recorder.db_test_schema': [{'end': 948589200.0, 'last_reset': None, 'max': None, 'mean': 2021.0, 'min': None, 'start': 948585600.0, 'state': None, 'sum': 394.5068}, {'end': 1601946000.000001, 'last_reset': 1601942400.000001, 'max': 1.000000000000001, 'mean': 1.000000000000001, 'min': 1.000000000000001, 'start': 1601942400.000001, 'state': 1.000000000000001, 'sum': 1.000000000000001}]})] ``` --- .../components/recorder/statistics.py | 28 +++++++++++++++---- tests/components/recorder/test_statistics.py | 7 ++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2f93a8a833e8..b117556b0afc 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2527,6 +2527,11 @@ def _validate_db_schema_utf8( return schema_errors +def _get_future_year() -> int: + """Get a year in the future.""" + return datetime.now().year + 1 + + def _validate_db_schema( hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] ) -> set[str]: @@ -2544,9 +2549,16 @@ def _validate_db_schema( # This number can't be accurately represented as a 32-bit float precise_number = 1.000000000000001 # This time can't be accurately represented unless datetimes have µs precision - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - - start_time = datetime(2020, 10, 6, tzinfo=dt_util.UTC) + # + # We want to insert statistics for a time in the future, in case they + # have conflicting metadata_id's with existing statistics that were + # never cleaned up. By inserting in the future, we can be sure that + # that by selecting the last inserted row, we will get the one we + # just inserted. + # + future_year = _get_future_year() + precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) statistic_id = f"{DOMAIN}.db_test" metadata: StatisticMetaData = { @@ -2614,9 +2626,15 @@ def _validate_db_schema( ) continue + # We want to look at the last inserted row to make sure there + # is not previous garbage data in the table that would cause + # the test to produce an incorrect result. To achieve this, + # we inserted a row in the future, and now we select the last + # inserted row back. + last_stored_statistic = stored_statistic[-1] check_columns( schema_errors, - stored_statistic[0], + last_stored_statistic, statistics, ("max", "mean", "min", "state", "sum"), table.__tablename__, @@ -2625,7 +2643,7 @@ def _validate_db_schema( assert statistics["last_reset"] check_columns( schema_errors, - stored_statistic[0], + last_stored_statistic, { "last_reset": datetime_to_timestamp_or_none( statistics["last_reset"] diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 4863c6c05475..ad4d0de410ee 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -26,6 +26,7 @@ from homeassistant.components.recorder.statistics import ( _generate_max_mean_min_statistic_in_sub_period_stmt, _generate_statistics_at_time_stmt, _generate_statistics_during_period_stmt, + _get_future_year, _statistics_during_period_with_session, async_add_external_statistics, async_import_statistics, @@ -1633,7 +1634,8 @@ async def test_validate_db_schema_fix_float_issue( orig_error = MagicMock() orig_error.args = [1366] precise_number = 1.000000000000001 - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + fixed_future_year = _get_future_year() + precise_time = datetime(fixed_future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) statistics = { "recorder.db_test": [ { @@ -1653,6 +1655,9 @@ async def test_validate_db_schema_fix_float_issue( with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.statistics._get_future_year", + return_value=fixed_future_year, ), patch( "homeassistant.components.recorder.statistics._statistics_during_period_with_session", side_effect=fake_statistics, From d33a303a83bc813c8abc35cb755e89773afb5e2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 18:06:37 -1000 Subject: [PATCH 0611/1058] =?UTF-8?q?Fix=20statistics=20schema=20=C2=B5s?= =?UTF-8?q?=20precision=20auto=20repair=20being=20ineffective=20(#89902)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a user manually migrated their database to MySQL or PostgresSQL and incorrectly created the timestamp columns as float we would fail to correct them to double because when we migrated to use timestamps for the columns I missed that we needed to change the columns and types for µs precision --- homeassistant/components/recorder/statistics.py | 14 +++++--------- tests/components/recorder/test_statistics.py | 6 +++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index b117556b0afc..989dc06db57c 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2590,8 +2590,8 @@ def _validate_db_schema( for column in columns: if stored[column] != expected[column]: schema_errors.add(f"{table_name}.{supports}") - _LOGGER.debug( - "Column %s in database table %s does not support %s (%s != %s)", + _LOGGER.error( + "Column %s in database table %s does not support %s (stored=%s != expected=%s)", column, table_name, supports, @@ -2727,18 +2727,14 @@ def correct_db_schema( ], ) if f"{table.__tablename__}.µs precision" in schema_errors: - # Attempt to convert datetime columns to µs precision - if instance.dialect_name == SupportedDialect.MYSQL: - datetime_type = "DATETIME(6)" - else: - datetime_type = "TIMESTAMP(6) WITH TIME ZONE" + # Attempt to convert timestamp columns to µs precision _modify_columns( session_maker, engine, table.__tablename__, [ - f"last_reset {datetime_type}", - f"start {datetime_type}", + "last_reset_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", ], ) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ad4d0de410ee..d783d72be2da 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1687,12 +1687,12 @@ async def test_validate_db_schema_fix_float_issue( @pytest.mark.parametrize( ("db_engine", "modification"), ( - ("mysql", ["last_reset DATETIME(6)", "start DATETIME(6)"]), + ("mysql", ["last_reset_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION"]), ( "postgresql", [ - "last_reset TIMESTAMP(6) WITH TIME ZONE", - "start TIMESTAMP(6) WITH TIME ZONE", + "last_reset_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", ], ), ), From f62bb0e2eaea4e187b9a81df7265422fd3837e26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Mar 2023 22:32:21 -1000 Subject: [PATCH 0612/1058] Bump zeroconf to 0.47.4 (#89973) --- homeassistant/components/zeroconf/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/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 02b5982e5869..b7a643bb46b7 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.47.3"] + "requirements": ["zeroconf==0.47.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 566dcc61b27c..22c2c289c605 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -48,7 +48,7 @@ ulid-transform==0.4.2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.47.3 +zeroconf==0.47.4 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 41b2b1452350..923103ec10ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2700,7 +2700,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.47.3 +zeroconf==0.47.4 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef0c26dc8fe8..2a2af951dc7e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1925,7 +1925,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.47.3 +zeroconf==0.47.4 # homeassistant.components.zeversolar zeversolar==0.3.1 From 9a784fddef7c6654969d38bdfab308c0edd87fb2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Mar 2023 10:20:19 +0100 Subject: [PATCH 0613/1058] Fail CI on lingering timers (#89292) --- tests/conftest.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 08ca75829c30..5a1c44b78193 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ import functools import gc import itertools import logging +import os import sqlite3 import ssl import threading @@ -262,6 +263,22 @@ def expected_lingering_tasks() -> bool: return False +@pytest.fixture(autouse=True) +def expected_lingering_timers() -> bool: + """Temporary ability to bypass test failures. + + Parametrize to True to bypass the pytest failure. + @pytest.mark.parametrize("expected_lingering_timers", [True]) + + This should be removed when all lingering timers have been cleaned up. + """ + current_test = os.getenv("PYTEST_CURRENT_TEST") + if current_test and current_test.startswith("tests/components"): + # As a starting point, we ignore components + return True + return False + + @pytest.fixture def wait_for_stop_scripts_after_shutdown() -> bool: """Add ability to bypass _schedule_stop_scripts_after_shutdown. @@ -291,7 +308,9 @@ def skip_stop_scripts( @pytest.fixture(autouse=True) def verify_cleanup( - event_loop: asyncio.AbstractEventLoop, expected_lingering_tasks: bool + event_loop: asyncio.AbstractEventLoop, + expected_lingering_tasks: bool, + expected_lingering_timers: bool, ) -> Generator[None, None, None]: """Verify that the test has cleaned up resources correctly.""" threads_before = frozenset(threading.enumerate()) @@ -311,16 +330,19 @@ def verify_cleanup( tasks = asyncio.all_tasks(event_loop) - tasks_before for task in tasks: if expected_lingering_tasks: - _LOGGER.warning("Linger task after test %r", task) + _LOGGER.warning("Lingering task after test %r", task) else: - pytest.fail(f"Linger task after test {repr(task)}") + pytest.fail(f"Lingering task after test {repr(task)}") task.cancel() if tasks: event_loop.run_until_complete(asyncio.wait(tasks)) for handle in event_loop._scheduled: # type: ignore[attr-defined] if not handle.cancelled(): - _LOGGER.warning("Lingering timer after test %r", handle) + if expected_lingering_timers: + _LOGGER.warning("Lingering timer after test %r", handle) + else: + pytest.fail(f"Lingering timer after test {repr(handle)}") handle.cancel() # Verify no threads where left behind. From c4ee35570d98a231713fe13bea93a0bd3b77b1c8 Mon Sep 17 00:00:00 2001 From: Jesse Moody Date: Mon, 20 Mar 2023 05:27:55 -0400 Subject: [PATCH 0614/1058] Update django github references to main instead of master branch. (#89951) --- homeassistant/util/dt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 09a7923aaaf0..2dfc9a6f622f 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -24,7 +24,7 @@ EPOCHORDINAL = dt.datetime(1970, 1, 1).toordinal() # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. -# https://github.com/django/django/blob/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE DATETIME_RE = re.compile( r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})" r"[T ](?P\d{1,2}):(?P\d{1,2})" @@ -34,7 +34,7 @@ DATETIME_RE = re.compile( # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. -# https://github.com/django/django/blob/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE STANDARD_DURATION_RE = re.compile( r"^" r"(?:(?P-?\d+) (days?, )?)?" @@ -48,7 +48,7 @@ STANDARD_DURATION_RE = re.compile( # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. -# https://github.com/django/django/blob/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE ISO8601_DURATION_RE = re.compile( r"^(?P[-+]?)" r"P" @@ -63,7 +63,7 @@ ISO8601_DURATION_RE = re.compile( # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. -# https://github.com/django/django/blob/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE POSTGRES_INTERVAL_RE = re.compile( r"^" r"(?:(?P-?\d+) (days? ?))?" @@ -178,7 +178,7 @@ 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/master/LICENSE +# https://github.com/django/django/blob/main/LICENSE def parse_datetime(dt_str: str) -> dt.datetime | None: """Parse a string and return a datetime.datetime. From f3b3818d1fff27f885c278b8b93fa92a35a445c2 Mon Sep 17 00:00:00 2001 From: micha91 Date: Mon, 20 Mar 2023 11:59:27 +0100 Subject: [PATCH 0615/1058] Bump aiomusiccast to 0.14.8 (#89978) --- homeassistant/components/yamaha_musiccast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 9a19f61eb441..48b8de20608f 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "iot_class": "local_push", "loggers": ["aiomusiccast"], - "requirements": ["aiomusiccast==0.14.7"], + "requirements": ["aiomusiccast==0.14.8"], "ssdp": [ { "manufacturer": "Yamaha Corporation" diff --git a/requirements_all.txt b/requirements_all.txt index 923103ec10ef..26d92aad914a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -214,7 +214,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.7 +aiomusiccast==0.14.8 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a2af951dc7e..342d00675492 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -198,7 +198,7 @@ aiolyric==1.0.9 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.7 +aiomusiccast==0.14.8 # homeassistant.components.nanoleaf aionanoleaf==0.2.1 From c3043fb0ee1ba292a8d0df67f407e2ad42e4d739 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 01:06:15 -1000 Subject: [PATCH 0616/1058] Bump bluetooth deps for bleak 0.20 (#89925) Co-authored-by: K --- .../components/bluetooth/base_scanner.py | 14 +- .../components/bluetooth/manifest.json | 8 +- .../components/bluetooth/wrappers.py | 24 ++- .../components/esphome/bluetooth/client.py | 6 +- homeassistant/package_constraints.txt | 8 +- requirements_all.txt | 8 +- requirements_test_all.txt | 8 +- tests/components/airthings_ble/__init__.py | 7 +- tests/components/aranet/__init__.py | 5 +- tests/components/bluetooth/__init__.py | 33 +++- .../bluetooth/test_advertisement_tracker.py | 16 +- tests/components/bluetooth/test_api.py | 12 +- .../components/bluetooth/test_base_scanner.py | 19 ++- .../components/bluetooth/test_diagnostics.py | 17 ++- tests/components/bluetooth/test_init.py | 143 ++++++++++-------- tests/components/bluetooth/test_manager.py | 69 +++++---- tests/components/bluetooth/test_models.py | 31 ++-- tests/components/bluetooth/test_scanner.py | 11 +- tests/components/bluetooth/test_usage.py | 11 +- tests/components/bluetooth/test_wrappers.py | 4 +- .../test_device_tracker.py | 13 +- tests/components/bthome/__init__.py | 19 ++- tests/components/dormakaba_dkey/__init__.py | 7 +- tests/components/fjaraskupan/__init__.py | 6 +- tests/components/ibeacon/__init__.py | 10 +- tests/components/ibeacon/test_coordinator.py | 4 +- tests/components/keymitt_ble/__init__.py | 6 +- tests/components/ld2410_ble/__init__.py | 7 +- tests/components/led_ble/__init__.py | 13 +- tests/components/melnor/conftest.py | 7 +- tests/components/oralb/__init__.py | 5 +- tests/components/snooz/__init__.py | 4 +- tests/components/switchbot/__init__.py | 20 ++- tests/components/xiaomi_ble/__init__.py | 19 ++- tests/components/yalexs_ble/__init__.py | 11 +- 35 files changed, 347 insertions(+), 258 deletions(-) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 903f14a92273..1c16639d6139 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -165,13 +165,13 @@ class BaseHaScanner(ABC): "monotonic_time": MONOTONIC_TIME(), "discovered_devices_and_advertisement_data": [ { - "name": device_adv[0].name, - "address": device_adv[0].address, - "rssi": device_adv[0].rssi, - "advertisement_data": device_adv[1], - "details": device_adv[0].details, + "name": device.name, + "address": device.address, + "rssi": advertisement_data.rssi, + "advertisement_data": advertisement_data, + "details": device.details, } - for device_adv in device_adv_datas + for device, advertisement_data in device_adv_datas ], } @@ -339,7 +339,7 @@ class BaseHaRemoteScanner(BaseHaScanner): tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, platform_data=(), ) - device = BLEDevice( # type: ignore[no-untyped-call] + device = BLEDevice( address=address, name=local_name, details=self._details | details, diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 8331117c9c96..f6cbe5b3e54c 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,11 +15,11 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.19.5", - "bleak-retry-connector==2.13.0", - "bluetooth-adapters==0.15.2", + "bleak==0.20.0", + "bleak-retry-connector==3.0.1", + "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", "bluetooth-data-tools==0.3.1", - "dbus-fast==1.84.1" + "dbus-fast==1.84.2" ] } diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 6b463423c73d..cf17796105b1 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -224,10 +224,28 @@ class HaBleakClientWrapper(BleakClient): self.__disconnected_callback = callback if self._backend: self._backend.set_disconnected_callback( - callback, # type: ignore[arg-type] + self._make_disconnected_callback(callback), **kwargs, ) + def _make_disconnected_callback( + self, callback: Callable[[BleakClient], None] | None + ) -> Callable[[], None] | None: + """Make the disconnected callback. + + https://github.com/hbldh/bleak/pull/1256 + The disconnected callback needs to get the top level + BleakClientWrapper instance, not the backend instance. + + The signature of the callback for the backend is: + Callable[[], None] + + To make this work we need to wrap the callback in a partial + that passes the BleakClientWrapper instance as the first + argument. + """ + return None if callback is None else partial(callback, self) + async def connect(self, **kwargs: Any) -> bool: """Connect to the specified GATT server.""" assert models.MANAGER is not None @@ -235,7 +253,9 @@ class HaBleakClientWrapper(BleakClient): wrapped_backend = self._async_get_best_available_backend_and_device(manager) self._backend = wrapped_backend.client( wrapped_backend.device, - disconnected_callback=self.__disconnected_callback, + disconnected_callback=self._make_disconnected_callback( + self.__disconnected_callback + ), timeout=self.__timeout, hass=manager.hass, ) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 343847f55fa0..71d081ff6a47 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -223,7 +223,7 @@ class ESPHomeClient(BaseBleakClient): def _async_call_bleak_disconnected_callback(self) -> None: """Call the disconnected callback to inform the bleak consumer.""" if self._disconnected_callback: - self._disconnected_callback(self) + self._disconnected_callback() self._disconnected_callback = None @api_error_as_bleak_error @@ -499,8 +499,10 @@ class ESPHomeClient(BaseBleakClient): self, char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID ) -> BleakGATTCharacteristic: """Resolve a characteristic specifier to a BleakGATTCharacteristic object.""" + if (services := self.services) is None: + raise BleakError("Services have not been resolved") if not isinstance(char_specifier, BleakGATTCharacteristic): - characteristic = self.services.get_characteristic(char_specifier) + characteristic = services.get_characteristic(char_specifier) else: characteristic = char_specifier if not characteristic: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 22c2c289c605..29665559e4dd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,15 +10,15 @@ atomicwrites-homeassistant==1.4.1 attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==2.13.0 -bleak==0.19.5 -bluetooth-adapters==0.15.2 +bleak-retry-connector==3.0.1 +bleak==0.20.0 +bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==39.0.1 -dbus-fast==1.84.1 +dbus-fast==1.84.2 fnvhash==0.1.0 hass-nabucasa==0.61.1 hassil==1.0.6 diff --git a/requirements_all.txt b/requirements_all.txt index 26d92aad914a..be03dac8c0eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,10 +431,10 @@ bimmer_connected==0.12.1 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==2.13.0 +bleak-retry-connector==3.0.1 # homeassistant.components.bluetooth -bleak==0.19.5 +bleak==0.20.0 # homeassistant.components.blebox blebox_uniapi==2.1.4 @@ -456,7 +456,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.15.2 +bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.0.3 @@ -563,7 +563,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.84.1 +dbus-fast==1.84.2 # homeassistant.components.debugpy debugpy==1.6.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 342d00675492..38d107577199 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,10 +361,10 @@ bellows==0.34.10 bimmer_connected==0.12.1 # homeassistant.components.bluetooth -bleak-retry-connector==2.13.0 +bleak-retry-connector==3.0.1 # homeassistant.components.bluetooth -bleak==0.19.5 +bleak==0.20.0 # homeassistant.components.blebox blebox_uniapi==2.1.4 @@ -376,7 +376,7 @@ blinkpy==0.19.2 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.15.2 +bluetooth-adapters==0.15.3 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.0.3 @@ -449,7 +449,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.84.1 +dbus-fast==1.84.2 # homeassistant.components.debugpy debugpy==1.6.6 diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index 7f8df35f263e..71875b9c4b12 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -4,11 +4,10 @@ from __future__ import annotations from unittest.mock import patch from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device def patch_async_setup_entry(return_value=True): @@ -45,7 +44,7 @@ WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={}, service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], source="local", - device=BLEDevice( + device=generate_ble_device( "cc:cc:cc:cc:cc:cc", "cc-cc-cc-cc-cc-cc", ), @@ -65,7 +64,7 @@ UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={}, service_uuids=[], source="local", - device=BLEDevice( + device=generate_ble_device( "cc:cc:cc:cc:cc:cc", "unknown", ), diff --git a/tests/components/aranet/__init__.py b/tests/components/aranet/__init__.py index 2fe27329bda1..c85748abea46 100644 --- a/tests/components/aranet/__init__.py +++ b/tests/components/aranet/__init__.py @@ -2,11 +2,12 @@ from time import time -from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from tests.components.bluetooth import generate_ble_device + def fake_service_info(name, service_uuid, manufacturer_data): """Return a BluetoothServiceInfoBleak for use in testing.""" @@ -20,7 +21,7 @@ def fake_service_info(name, service_uuid, manufacturer_data): source="local", connectable=False, time=time(), - device=BLEDevice("aa:bb:cc:dd:ee:ff", name=name), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", name=name), advertisement=AdvertisementData( local_name=name, manufacturer_data=manufacturer_data, diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 91016206e8a8..3aedd6f2deb3 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -33,6 +33,7 @@ __all__ = ( "patch_all_discovered_devices", "patch_discovered_devices", "generate_advertisement_data", + "generate_ble_device", "MockBleakClient", ) @@ -46,6 +47,12 @@ ADVERTISEMENT_DATA_DEFAULTS = { "tx_power": -127, } +BLE_DEVICE_DEFAULTS = { + "name": None, + "rssi": -127, + "details": None, +} + def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" @@ -55,6 +62,28 @@ def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: return AdvertisementData(**new) +def generate_ble_device( + address: str | None = None, + name: str | None = None, + details: Any | None = None, + rssi: int | None = None, + **kwargs: Any, +) -> BLEDevice: + """Generate a BLEDevice with defaults.""" + new = kwargs.copy() + if address is not None: + new["address"] = address + if name is not None: + new["name"] = name + if details is not None: + new["details"] = details + if rssi is not None: + new["rssi"] = rssi + for key, value in BLE_DEVICE_DEFAULTS.items(): + new.setdefault(key, value) + return BLEDevice(**new) + + def _get_manager() -> BluetoothManager: """Return the bluetooth manager.""" return models.MANAGER @@ -126,7 +155,7 @@ def inject_bluetooth_service_info_bleak( service_uuids=info.service_uuids, rssi=info.rssi, ) - device = BLEDevice( # type: ignore[no-untyped-call] + device = generate_ble_device( # type: ignore[no-untyped-call] address=info.address, name=info.name, details={}, @@ -152,7 +181,7 @@ def inject_bluetooth_service_info( service_uuids=info.service_uuids, rssi=info.rssi, ) - device = BLEDevice( # type: ignore[no-untyped-call] + device = generate_ble_device( # type: ignore[no-untyped-call] address=info.address, name=info.name, details={}, diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index 88106a029ddf..5a2c55259bbf 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -3,7 +3,6 @@ from datetime import timedelta import time from unittest.mock import patch -from bleak.backends.scanner import BLEDevice import pytest from homeassistant.components.bluetooth import ( @@ -24,6 +23,7 @@ from homeassistant.util import dt as dt_util from . import ( FakeScanner, generate_advertisement_data, + generate_ble_device, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, ) @@ -41,7 +41,7 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout( ) -> None: """Test we can determine the advertisement interval.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:12", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:12", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -88,7 +88,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectab ) -> None: """Test device with a long advertisement interval.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:18", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:18", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -137,7 +137,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) -> None: """Test device with a long advertisement interval with an adapter change.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -195,7 +195,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_conne ) -> None: """Test device with a long advertisement interval that is not connectable not reaching the advertising interval.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -247,7 +247,7 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ ) -> None: """Test device with a short advertisement interval with an adapter change that is not connectable.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:5C", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:5C", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -315,7 +315,7 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) -> None: """Test device with a long advertisement interval with an adapter change that is not connectable.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -416,7 +416,7 @@ async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeou ) -> None: """Test device with a increasing advertisement interval with an adapter change that is not connectable.""" start_monotonic_time = time.monotonic() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 3aaa2ce7efa9..77d802264e19 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -11,7 +11,13 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.core import HomeAssistant -from . import FakeScanner, MockBleakClient, _get_manager, generate_advertisement_data +from . import ( + FakeScanner, + MockBleakClient, + _get_manager, + generate_advertisement_data, + generate_ble_device, +) async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> None: @@ -56,7 +62,7 @@ async def test_async_scanner_devices_by_address_connectable( ) unsetup = scanner.async_setup() cancel = manager.async_register_scanner(scanner, True) - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -89,7 +95,7 @@ async def test_async_scanner_devices_by_address_non_connectable( ) -> None: """Test getting scanner devices by address with non-connectable devices.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 14722d81ae6d..79a36630df22 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -29,7 +29,12 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads -from . import MockBleakClient, _get_manager, generate_advertisement_data +from . import ( + MockBleakClient, + _get_manager, + generate_advertisement_data, + generate_ble_device, +) from tests.common import async_fire_time_changed, load_fixture @@ -38,7 +43,7 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No """Test the remote scanner base class merges advertisement_data.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -51,7 +56,7 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No manufacturer_data={1: b"\x01"}, rssi=-100, ) - switchbot_device_2 = BLEDevice( + switchbot_device_2 = generate_ble_device( "44:44:33:11:23:45", "w", {}, @@ -126,7 +131,7 @@ async def test_remote_scanner_expires_connectable( """Test the remote scanner expires stale connectable data.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -200,7 +205,7 @@ async def test_remote_scanner_expires_non_connectable( """Test the remote scanner expires stale non connectable data.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -297,7 +302,7 @@ async def test_base_scanner_connecting_behavior( """Test that the default behavior is to mark the scanner as not scanning when connecting.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -420,7 +425,7 @@ async def test_device_with_ten_minute_advertising_interval( """Test a device with a 10 minute advertising interval.""" manager = _get_manager() - bparasite_device = BLEDevice( + bparasite_device = generate_ble_device( "44:44:33:11:23:45", "bparasite", {}, diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index ced401417e32..7ffd3f001312 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -12,6 +12,7 @@ from . import ( MockBleakClient, _get_manager, generate_advertisement_data, + generate_ble_device, inject_advertisement, ) @@ -37,7 +38,7 @@ async def test_diagnostics( "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", { "44:44:33:11:23:45": ( - BLEDevice(name="x", rssi=-60, address="44:44:33:11:23:45"), + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), generate_advertisement_data(local_name="x"), ) }, @@ -174,7 +175,7 @@ async def test_diagnostics( ], "details": None, "name": "x", - "rssi": -60, + "rssi": -127, } ], "last_detection": ANY, @@ -201,7 +202,7 @@ async def test_diagnostics( ], "details": None, "name": "x", - "rssi": -60, + "rssi": -127, } ], "last_detection": ANY, @@ -228,7 +229,7 @@ async def test_diagnostics( ], "details": None, "name": "x", - "rssi": -60, + "rssi": -127, } ], "last_detection": ANY, @@ -257,7 +258,7 @@ async def test_diagnostics_macos( # because we cannot import the scanner class directly without it throwing an # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -266,7 +267,7 @@ async def test_diagnostics_macos( "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", { "44:44:33:11:23:45": ( - BLEDevice(name="x", rssi=-60, address="44:44:33:11:23:45"), + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), switchbot_adv, ) }, @@ -404,7 +405,7 @@ async def test_diagnostics_macos( ], "details": None, "name": "x", - "rssi": -60, + "rssi": -127, } ], "last_detection": ANY, @@ -430,7 +431,7 @@ async def test_diagnostics_remote_adapter( ) -> None: """Test diagnostics for remote adapter.""" manager = _get_manager() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 5bb1eeb977c4..66ef0c4a1421 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -49,6 +49,7 @@ from . import ( _get_manager, async_setup_with_default_adapter, generate_advertisement_data, + generate_ble_device, inject_advertisement, inject_advertisement_with_time_and_source_connectable, patch_discovered_devices, @@ -354,7 +355,7 @@ async def test_discovery_match_by_service_uuid( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -364,7 +365,7 @@ async def test_discovery_match_by_service_uuid( assert len(mock_config_flow.mock_calls) == 0 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -401,7 +402,7 @@ async def test_discovery_match_by_service_uuid_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -413,7 +414,7 @@ async def test_discovery_match_by_service_uuid_connectable( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -448,7 +449,7 @@ async def test_discovery_match_by_service_uuid_not_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -460,7 +461,7 @@ async def test_discovery_match_by_service_uuid_not_connectable( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -493,7 +494,7 @@ async def test_discovery_match_by_name_connectable_false( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -505,7 +506,9 @@ async def test_discovery_match_by_name_connectable_false( assert len(_domains_from_mock_config_flow(mock_config_flow)) == 0 - qingping_device = BLEDevice("44:44:33:11:23:45", "Qingping Motion & Light") + qingping_device = generate_ble_device( + "44:44:33:11:23:45", "Qingping Motion & Light" + ) qingping_adv = generate_advertisement_data( local_name="Qingping Motion & Light", service_data={ @@ -561,7 +564,7 @@ async def test_discovery_match_by_local_name( assert len(mock_bleak_scanner_start.mock_calls) == 1 - wrong_device = BLEDevice("44:44:33:11:23:45", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:45", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) @@ -571,7 +574,7 @@ async def test_discovery_match_by_local_name( assert len(mock_config_flow.mock_calls) == 0 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -605,7 +608,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( assert len(mock_bleak_scanner_start.mock_calls) == 1 - hkc_device = BLEDevice("44:44:33:11:23:45", "lock") + hkc_device = generate_ble_device("44:44:33:11:23:45", "lock") hkc_adv_no_mfr_data = generate_advertisement_data( local_name="lock", service_uuids=[], @@ -639,7 +642,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( assert len(mock_config_flow.mock_calls) == 0 mock_config_flow.reset_mock() - not_hkc_device = BLEDevice("44:44:33:11:23:21", "lock") + not_hkc_device = generate_ble_device("44:44:33:11:23:21", "lock") not_hkc_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={76: b"\x02"} ) @@ -648,7 +651,7 @@ async def test_discovery_match_by_manufacturer_id_and_manufacturer_data_start( await hass.async_block_till_done() assert len(mock_config_flow.mock_calls) == 0 - not_apple_device = BLEDevice("44:44:33:11:23:23", "lock") + not_apple_device = generate_ble_device("44:44:33:11:23:23", "lock") not_apple_adv = generate_advertisement_data( local_name="lock", service_uuids=[], manufacturer_data={21: b"\x02"} ) @@ -688,7 +691,7 @@ async def test_discovery_match_by_service_data_uuid_then_others( assert len(mock_bleak_scanner_start.mock_calls) == 1 - device = BLEDevice("44:44:33:11:23:45", "lock") + device = generate_ble_device("44:44:33:11:23:45", "lock") adv_without_service_data_uuid = generate_advertisement_data( local_name="lock", service_uuids=[], @@ -838,7 +841,7 @@ async def test_discovery_match_by_service_data_uuid_when_format_changes( assert len(mock_bleak_scanner_start.mock_calls) == 1 - device = BLEDevice("44:44:33:11:23:45", "lock") + device = generate_ble_device("44:44:33:11:23:45", "lock") adv_without_service_data_uuid = generate_advertisement_data( local_name="Qingping Temp RH M", service_uuids=[], @@ -921,7 +924,7 @@ async def test_discovery_match_first_by_service_uuid_and_then_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 - device = BLEDevice("44:44:33:11:23:45", "lock") + device = generate_ble_device("44:44:33:11:23:45", "lock") adv_service_uuids = generate_advertisement_data( local_name="lock", service_uuids=["0000fd3d-0000-1000-8000-00805f9b34fc"], @@ -976,7 +979,7 @@ async def test_rediscovery( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] ) @@ -1026,12 +1029,12 @@ async def test_async_discovered_device_api( assert not bluetooth.async_discovered_service_info(hass) - wrong_device = BLEDevice("44:44:33:11:23:42", "wrong_name") + wrong_device = generate_ble_device("44:44:33:11:23:42", "wrong_name") wrong_adv = generate_advertisement_data( local_name="wrong_name", service_uuids=[] ) inject_advertisement(hass, wrong_device, wrong_adv) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[] ) @@ -1119,7 +1122,7 @@ async def test_register_callbacks( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - seen_switchbot_device = BLEDevice("44:44:33:11:23:46", "wohand") + seen_switchbot_device = generate_ble_device("44:44:33:11:23:46", "wohand") seen_switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1138,7 +1141,7 @@ async def test_register_callbacks( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1148,13 +1151,13 @@ async def test_register_callbacks( inject_advertisement(hass, switchbot_device, switchbot_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -1209,7 +1212,7 @@ async def test_register_callbacks_raises_exception( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1268,7 +1271,7 @@ async def test_register_callback_by_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1278,13 +1281,13 @@ async def test_register_callback_by_address( inject_advertisement(hass, switchbot_device, switchbot_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) await hass.async_block_till_done() - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") # 3rd callback raises ValueError but is still tracked @@ -1370,7 +1373,7 @@ async def test_register_callback_by_address_connectable_only( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1436,7 +1439,7 @@ async def test_register_callback_by_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:45", "rtx") apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1444,7 +1447,7 @@ async def test_register_callback_by_manufacturer_id( inject_advertisement(hass, apple_device, apple_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -1491,7 +1494,7 @@ async def test_register_callback_by_connectable( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:45", "rtx") apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={7676: b"\xd8.\xad\xcd\r\x85"}, @@ -1499,7 +1502,7 @@ async def test_register_callback_by_connectable( inject_advertisement(hass, apple_device, apple_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -1546,7 +1549,7 @@ async def test_not_filtering_wanted_apple_devices( assert len(mock_bleak_scanner_start.mock_calls) == 1 - ibeacon_device = BLEDevice("44:44:33:11:23:45", "rtx") + ibeacon_device = generate_ble_device("44:44:33:11:23:45", "rtx") ibeacon_adv = generate_advertisement_data( local_name="ibeacon", manufacturer_data={76: b"\x02\x00\x00\x00"}, @@ -1554,7 +1557,7 @@ async def test_not_filtering_wanted_apple_devices( inject_advertisement(hass, ibeacon_device, ibeacon_adv) - homekit_device = BLEDevice("44:44:33:11:23:46", "rtx") + homekit_device = generate_ble_device("44:44:33:11:23:46", "rtx") homekit_adv = generate_advertisement_data( local_name="homekit", manufacturer_data={76: b"\x06\x00\x00\x00"}, @@ -1562,7 +1565,7 @@ async def test_not_filtering_wanted_apple_devices( inject_advertisement(hass, homekit_device, homekit_adv) - apple_device = BLEDevice("44:44:33:11:23:47", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:47", "rtx") apple_adv = generate_advertisement_data( local_name="apple", manufacturer_data={76: b"\x10\x00\x00\x00"}, @@ -1606,7 +1609,7 @@ async def test_filtering_noisy_apple_devices( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:45", "rtx") apple_adv = generate_advertisement_data( local_name="noisy", manufacturer_data={76: b"\xd8.\xad\xcd\r\x85"}, @@ -1614,7 +1617,7 @@ async def test_filtering_noisy_apple_devices( inject_advertisement(hass, apple_device, apple_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -1656,7 +1659,7 @@ async def test_register_callback_by_address_connectable_manufacturer_id( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "rtx") + apple_device = generate_ble_device("44:44:33:11:23:45", "rtx") apple_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1664,7 +1667,7 @@ async def test_register_callback_by_address_connectable_manufacturer_id( inject_advertisement(hass, apple_device, apple_adv) - apple_device_wrong_address = BLEDevice("44:44:33:11:23:46", "rtx") + apple_device_wrong_address = generate_ble_device("44:44:33:11:23:46", "rtx") inject_advertisement(hass, apple_device_wrong_address, apple_adv) await hass.async_block_till_done() @@ -1710,7 +1713,7 @@ async def test_register_callback_by_manufacturer_id_and_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 - rtx_device = BLEDevice("44:44:33:11:23:45", "rtx") + rtx_device = generate_ble_device("44:44:33:11:23:45", "rtx") rtx_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1718,7 +1721,7 @@ async def test_register_callback_by_manufacturer_id_and_address( inject_advertisement(hass, rtx_device, rtx_adv) - yale_device = BLEDevice("44:44:33:11:23:45", "apple") + yale_device = generate_ble_device("44:44:33:11:23:45", "apple") yale_adv = generate_advertisement_data( local_name="yale", manufacturer_data={465: b"\xd8.\xad\xcd\r\x85"}, @@ -1727,7 +1730,7 @@ async def test_register_callback_by_manufacturer_id_and_address( inject_advertisement(hass, yale_device, yale_adv) await hass.async_block_till_done() - other_apple_device = BLEDevice("44:44:33:11:23:22", "apple") + other_apple_device = generate_ble_device("44:44:33:11:23:22", "apple") other_apple_adv = generate_advertisement_data( local_name="apple", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1778,7 +1781,7 @@ async def test_register_callback_by_service_uuid_and_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_dev = BLEDevice("44:44:33:11:23:45", "switchbot") + switchbot_dev = generate_ble_device("44:44:33:11:23:45", "switchbot") switchbot_adv = generate_advertisement_data( local_name="switchbot", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1786,7 +1789,9 @@ async def test_register_callback_by_service_uuid_and_address( inject_advertisement(hass, switchbot_dev, switchbot_adv) - switchbot_missing_service_uuid_dev = BLEDevice("44:44:33:11:23:45", "switchbot") + switchbot_missing_service_uuid_dev = generate_ble_device( + "44:44:33:11:23:45", "switchbot" + ) switchbot_missing_service_uuid_adv = generate_advertisement_data( local_name="switchbot", ) @@ -1796,7 +1801,9 @@ async def test_register_callback_by_service_uuid_and_address( ) await hass.async_block_till_done() - service_uuid_wrong_address_dev = BLEDevice("44:44:33:11:23:22", "switchbot2") + service_uuid_wrong_address_dev = generate_ble_device( + "44:44:33:11:23:22", "switchbot2" + ) service_uuid_wrong_address_adv = generate_advertisement_data( local_name="switchbot2", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -1847,7 +1854,7 @@ async def test_register_callback_by_service_data_uuid_and_address( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_dev = BLEDevice("44:44:33:11:23:45", "switchbot") + switchbot_dev = generate_ble_device("44:44:33:11:23:45", "switchbot") switchbot_adv = generate_advertisement_data( local_name="switchbot", service_data={"cba20d00-224d-11e6-9fb8-0002a5d5c51b": b"x"}, @@ -1855,7 +1862,9 @@ async def test_register_callback_by_service_data_uuid_and_address( inject_advertisement(hass, switchbot_dev, switchbot_adv) - switchbot_missing_service_uuid_dev = BLEDevice("44:44:33:11:23:45", "switchbot") + switchbot_missing_service_uuid_dev = generate_ble_device( + "44:44:33:11:23:45", "switchbot" + ) switchbot_missing_service_uuid_adv = generate_advertisement_data( local_name="switchbot", ) @@ -1865,7 +1874,9 @@ async def test_register_callback_by_service_data_uuid_and_address( ) await hass.async_block_till_done() - service_uuid_wrong_address_dev = BLEDevice("44:44:33:11:23:22", "switchbot2") + service_uuid_wrong_address_dev = generate_ble_device( + "44:44:33:11:23:22", "switchbot2" + ) service_uuid_wrong_address_adv = generate_advertisement_data( local_name="switchbot2", service_data={"cba20d00-224d-11e6-9fb8-0002a5d5c51b": b"x"}, @@ -1913,7 +1924,7 @@ async def test_register_callback_by_local_name( assert len(mock_bleak_scanner_start.mock_calls) == 1 - rtx_device = BLEDevice("44:44:33:11:23:45", "rtx") + rtx_device = generate_ble_device("44:44:33:11:23:45", "rtx") rtx_adv = generate_advertisement_data( local_name="rtx", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -1921,12 +1932,12 @@ async def test_register_callback_by_local_name( inject_advertisement(hass, rtx_device, rtx_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) - rtx_device_2 = BLEDevice("44:44:33:11:23:45", "rtx") + rtx_device_2 = generate_ble_device("44:44:33:11:23:45", "rtx") rtx_adv_2 = generate_advertisement_data( local_name="rtx2", manufacturer_data={21: b"\xd8.\xad\xcd\r\x85"}, @@ -2012,7 +2023,7 @@ async def test_register_callback_by_service_data_uuid( assert len(mock_bleak_scanner_start.mock_calls) == 1 - apple_device = BLEDevice("44:44:33:11:23:45", "xiaomi") + apple_device = generate_ble_device("44:44:33:11:23:45", "xiaomi") apple_adv = generate_advertisement_data( local_name="xiaomi", service_data={ @@ -2022,7 +2033,7 @@ async def test_register_callback_by_service_data_uuid( inject_advertisement(hass, apple_device, apple_adv) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") inject_advertisement(hass, empty_device, empty_adv) @@ -2066,7 +2077,7 @@ async def test_register_callback_survives_reload( assert len(mock_bleak_scanner_start.mock_calls) == 1 - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["zba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2120,7 +2131,7 @@ async def test_process_advertisements_bail_on_good_advertisement( ) while not done.done(): - device = BLEDevice("aa:44:33:11:23:45", "wohand") + device = generate_ble_device("aa:44:33:11:23:45", "wohand") adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], @@ -2145,7 +2156,7 @@ async def test_process_advertisements_ignore_bad_advertisement( done = asyncio.Event() return_value = asyncio.Event() - device = BLEDevice("aa:44:33:11:23:45", "wohand") + device = generate_ble_device("aa:44:33:11:23:45", "wohand") adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51a"], @@ -2227,7 +2238,7 @@ async def test_wrapped_instance_with_filter( """Handle a detected device.""" detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2240,7 +2251,7 @@ async def test_wrapped_instance_with_filter( manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None @@ -2299,7 +2310,7 @@ async def test_wrapped_instance_with_service_uuids( """Handle a detected device.""" detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2312,7 +2323,7 @@ async def test_wrapped_instance_with_service_uuids( manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None @@ -2357,7 +2368,7 @@ async def test_wrapped_instance_with_broken_callbacks( raise ValueError detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2398,7 +2409,7 @@ async def test_wrapped_instance_changes_uuids( """Handle a detected device.""" detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2411,7 +2422,7 @@ async def test_wrapped_instance_changes_uuids( manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - empty_device = BLEDevice("11:22:33:44:55:66", "empty") + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None @@ -2453,7 +2464,7 @@ async def test_wrapped_instance_changes_filters( """Handle a detected device.""" detected.append((device, advertisement_data)) - switchbot_device = BLEDevice("44:44:33:11:23:42", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:42", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], @@ -2466,7 +2477,7 @@ async def test_wrapped_instance_changes_filters( manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, ) - empty_device = BLEDevice("11:22:33:44:55:62", "empty") + empty_device = generate_ble_device("11:22:33:44:55:62", "empty") empty_adv = generate_advertisement_data(local_name="empty") assert _get_manager() is not None @@ -2541,7 +2552,7 @@ async def test_async_ble_device_from_address( assert not bluetooth.async_discovered_service_info(hass) - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[] ) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 7e605ece4cb0..9d8d20bec5e5 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -37,6 +37,7 @@ from . import ( MockBleakClient, _get_manager, generate_advertisement_data, + generate_ble_device, inject_advertisement_with_source, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, @@ -73,7 +74,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( address = "44:44:33:11:23:12" - switchbot_device_signal_100 = BLEDevice(address, "wohand_signal_100", rssi=-100) + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) switchbot_adv_signal_100 = generate_advertisement_data( local_name="wohand_signal_100", service_uuids=[] ) @@ -86,7 +89,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_100 ) - switchbot_device_signal_99 = BLEDevice(address, "wohand_signal_99", rssi=-99) + switchbot_device_signal_99 = generate_ble_device( + address, "wohand_signal_99", rssi=-99 + ) switchbot_adv_signal_99 = generate_advertisement_data( local_name="wohand_signal_99", service_uuids=[] ) @@ -99,7 +104,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_99 ) - switchbot_device_signal_98 = BLEDevice(address, "wohand_good_signal", rssi=-98) + switchbot_device_signal_98 = generate_ble_device( + address, "wohand_good_signal", rssi=-98 + ) switchbot_adv_signal_98 = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[] ) @@ -124,7 +131,7 @@ async def test_switching_adapters_based_on_rssi( address = "44:44:33:11:23:45" - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -137,7 +144,7 @@ async def test_switching_adapters_based_on_rssi( is switchbot_device_poor_signal ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -159,7 +166,9 @@ async def test_switching_adapters_based_on_rssi( ) # We should not switch adapters unless the signal hits the threshold - switchbot_device_similar_signal = BLEDevice(address, "wohand_similar_signal") + switchbot_device_similar_signal = generate_ble_device( + address, "wohand_similar_signal" + ) switchbot_adv_similar_signal = generate_advertisement_data( local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) @@ -183,7 +192,7 @@ async def test_switching_adapters_based_on_zero_rssi( address = "44:44:33:11:23:45" - switchbot_device_no_rssi = BLEDevice(address, "wohand_poor_signal") + switchbot_device_no_rssi = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_no_rssi = generate_advertisement_data( local_name="wohand_no_rssi", service_uuids=[], rssi=0 ) @@ -196,7 +205,7 @@ async def test_switching_adapters_based_on_zero_rssi( is switchbot_device_no_rssi ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -218,7 +227,9 @@ async def test_switching_adapters_based_on_zero_rssi( ) # We should not switch adapters unless the signal hits the threshold - switchbot_device_similar_signal = BLEDevice(address, "wohand_similar_signal") + switchbot_device_similar_signal = generate_ble_device( + address, "wohand_similar_signal" + ) switchbot_adv_similar_signal = generate_advertisement_data( local_name="wohand_similar_signal", service_uuids=[], rssi=-62 ) @@ -243,7 +254,9 @@ async def test_switching_adapters_based_on_stale( address = "44:44:33:11:23:41" start_time_monotonic = 50.0 - switchbot_device_poor_signal_hci0 = BLEDevice(address, "wohand_poor_signal_hci0") + 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 ) @@ -260,7 +273,9 @@ async def test_switching_adapters_based_on_stale( is switchbot_device_poor_signal_hci0 ) - switchbot_device_poor_signal_hci1 = BLEDevice(address, "wohand_poor_signal_hci1") + 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 ) @@ -301,7 +316,7 @@ async def test_restore_history_from_dbus( """Test we can restore history from dbus.""" address = "AA:BB:CC:CC:CC:FF" - ble_device = BLEDevice(address, "name") + ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), "hci0" @@ -337,7 +352,7 @@ async def test_restore_history_from_dbus_and_remote_adapters( for address in timestamps: timestamps[address] = now - ble_device = BLEDevice(address, "name") + ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), "hci0" @@ -377,7 +392,7 @@ async def test_restore_history_from_dbus_and_corrupted_remote_adapters( for address in timestamps: timestamps[address] = now - ble_device = BLEDevice(address, "name") + ble_device = generate_ble_device(address, "name") history = { address: AdvertisementHistory( ble_device, generate_advertisement_data(local_name="name"), "hci0" @@ -406,7 +421,7 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( address = "44:44:33:11:23:45" now = time.monotonic() - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -422,7 +437,7 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( bluetooth.async_ble_device_from_address(hass, address, True) is switchbot_device_poor_signal ) - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -459,7 +474,9 @@ async def test_switching_adapters_based_on_rssi_connectable_to_non_connectable( bluetooth.async_ble_device_from_address(hass, address, True) is switchbot_device_poor_signal ) - switchbot_device_excellent_signal = BLEDevice(address, "wohand_excellent_signal") + switchbot_device_excellent_signal = generate_ble_device( + address, "wohand_excellent_signal" + ) switchbot_adv_excellent_signal = generate_advertisement_data( local_name="wohand_excellent_signal", service_uuids=[], rssi=-25 ) @@ -496,7 +513,7 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ address = "44:44:33:11:23:45" now = time.monotonic() - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -515,7 +532,7 @@ async def test_connectable_advertisement_can_be_retrieved_with_best_path_is_non_ ) assert bluetooth.async_ble_device_from_address(hass, address, True) is None - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -543,7 +560,7 @@ async def test_switching_adapters_when_one_goes_away( address = "44:44:33:11:23:45" - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -556,7 +573,7 @@ async def test_switching_adapters_when_one_goes_away( is switchbot_device_good_signal ) - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -593,7 +610,7 @@ async def test_switching_adapters_when_one_stop_scanning( address = "44:44:33:11:23:45" - switchbot_device_good_signal = BLEDevice(address, "wohand_good_signal") + switchbot_device_good_signal = generate_ble_device(address, "wohand_good_signal") switchbot_adv_good_signal = generate_advertisement_data( local_name="wohand_good_signal", service_uuids=[], rssi=-60 ) @@ -606,7 +623,7 @@ async def test_switching_adapters_when_one_stop_scanning( is switchbot_device_good_signal ) - switchbot_device_poor_signal = BLEDevice(address, "wohand_poor_signal") + switchbot_device_poor_signal = generate_ble_device(address, "wohand_poor_signal") switchbot_adv_poor_signal = generate_advertisement_data( local_name="wohand_poor_signal", service_uuids=[], rssi=-100 ) @@ -645,13 +662,13 @@ async def test_goes_unavailable_connectable_only_and_recovers( assert async_scanner_count(hass, connectable=True) == 0 assert async_scanner_count(hass, connectable=False) == 0 - switchbot_device_connectable = BLEDevice( + switchbot_device_connectable = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, rssi=-100, ) - switchbot_device_non_connectable = BLEDevice( + switchbot_device_non_connectable = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, @@ -813,7 +830,7 @@ async def test_goes_unavailable_dismisses_discovery( await hass.async_block_till_done() assert async_scanner_count(hass, connectable=False) == 0 - switchbot_device_non_connectable = BLEDevice( + switchbot_device_non_connectable = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index d17583bcceff..8331a8b6b76a 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -24,6 +24,7 @@ from . import ( MockBleakClient, _get_manager, generate_advertisement_data, + generate_ble_device, inject_advertisement, inject_advertisement_with_source, ) @@ -34,7 +35,7 @@ async def test_wrapped_bleak_scanner( ) -> None: """Test wrapped bleak scanner dispatches calls as expected.""" scanner = HaBleakScannerWrapper() - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") switchbot_adv = generate_advertisement_data( local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} ) @@ -47,7 +48,7 @@ async def test_wrapped_bleak_client_raises_device_missing( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test wrapped bleak client dispatches calls as expected.""" - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") client = HaBleakClientWrapper(switchbot_device) assert client.is_connected is False with pytest.raises(bleak.BleakError): @@ -61,7 +62,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_before_connected( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test wrapped bleak client can set a disconnected callback before connected.""" - switchbot_device = BLEDevice("44:44:33:11:23:45", "wohand") + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") client = HaBleakClientWrapper(switchbot_device) client.set_disconnected_callback(lambda client: None) @@ -72,7 +73,7 @@ async def test_wrapped_bleak_client_local_adapter_only( """Test wrapped bleak client with only a local adapter.""" manager = _get_manager() - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"}, @@ -133,7 +134,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( """Test wrapped bleak client can set a disconnected callback after connected.""" manager = _get_manager() - switchbot_proxy_device_has_connection_slot = BLEDevice( + switchbot_proxy_device_has_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -148,7 +149,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( manufacturer_data={1: b"\x01"}, rssi=-40, ) - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"}, @@ -220,7 +221,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_no_scanners( """Test we switch to the next available proxy when one runs out of connections with no scanners.""" manager = _get_manager() - switchbot_proxy_device_no_connection_slot = BLEDevice( + switchbot_proxy_device_no_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -257,7 +258,7 @@ async def test_ble_device_with_proxy_client_out_of_connections( """Test handling all scanners are out of connection slots.""" manager = _get_manager() - switchbot_proxy_device_no_connection_slot = BLEDevice( + switchbot_proxy_device_no_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -322,7 +323,7 @@ async def test_ble_device_with_proxy_clear_cache( """Test we can clear cache on the proxy.""" manager = _get_manager() - switchbot_proxy_device_with_connection_slot = BLEDevice( + switchbot_proxy_device_with_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -384,7 +385,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab """Test we switch to the next available proxy when one runs out of connections.""" manager = _get_manager() - switchbot_proxy_device_no_connection_slot = BLEDevice( + switchbot_proxy_device_no_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -398,7 +399,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab manufacturer_data={1: b"\x01"}, rssi=-30, ) - switchbot_proxy_device_has_connection_slot = BLEDevice( + switchbot_proxy_device_has_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand", { @@ -413,7 +414,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab manufacturer_data={1: b"\x01"}, rssi=-40, ) - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {"path": "/org/bluez/hci0/dev_44_44_33_11_23_45"}, @@ -493,7 +494,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab """Test we switch to the next available proxy when one runs out of connections on MacOS.""" manager = _get_manager() - switchbot_proxy_device_no_connection_slot = BLEDevice( + switchbot_proxy_device_no_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand_no_connection_slot", { @@ -509,7 +510,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab manufacturer_data={1: b"\x01"}, rssi=-30, ) - switchbot_proxy_device_has_connection_slot = BLEDevice( + switchbot_proxy_device_has_connection_slot = generate_ble_device( "44:44:33:11:23:45", "wohand_has_connection_slot", { @@ -525,7 +526,7 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab rssi=-40, ) - switchbot_device = BLEDevice( + switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 3417d1fd2130..81f9765de038 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -5,7 +5,7 @@ import time from unittest.mock import MagicMock, patch from bleak import BleakError -from bleak.backends.scanner import AdvertisementDataCallback, BLEDevice +from bleak.backends.scanner import AdvertisementDataCallback from dbus_fast import InvalidMessageError import pytest @@ -20,7 +20,12 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import _get_manager, async_setup_with_one_adapter, generate_advertisement_data +from . import ( + _get_manager, + async_setup_with_one_adapter, + generate_advertisement_data, + generate_ble_device, +) from tests.common import async_fire_time_changed @@ -236,7 +241,7 @@ async def test_recovery_from_dbus_restart( return_value=start_time_monotonic, ): _callback( - BLEDevice("44:44:33:11:23:42", "any_name"), + generate_ble_device("44:44:33:11:23:42", "any_name"), generate_advertisement_data(local_name="any_name"), ) diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index cb7bdd1038be..0edab3ce77bb 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -2,7 +2,6 @@ from unittest.mock import patch import bleak -from bleak.backends.device import BLEDevice import bleak_retry_connector import pytest @@ -16,10 +15,14 @@ from homeassistant.components.bluetooth.wrappers import ( ) from homeassistant.core import HomeAssistant -from . import _get_manager +from . import _get_manager, generate_ble_device -MOCK_BLE_DEVICE = BLEDevice( - "00:00:00:00:00:00", "any", delegate="", details={"path": "/dev/hci0/device"} +MOCK_BLE_DEVICE = generate_ble_device( + "00:00:00:00:00:00", + "any", + delegate="", + details={"path": "/dev/hci0/device"}, + rssi=-127, ) diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index 00cf70d5a0cd..e1656b39c184 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -21,7 +21,7 @@ from homeassistant.components.bluetooth.usage import ( ) from homeassistant.core import HomeAssistant -from . import _get_manager, generate_advertisement_data +from . import _get_manager, generate_advertisement_data, generate_ble_device class FakeScanner(BaseHaRemoteScanner): @@ -108,7 +108,7 @@ def _generate_ble_device_and_adv_data( ) -> tuple[BLEDevice, AdvertisementData]: """Generate a BLE device with adv data.""" return ( - BLEDevice( + generate_ble_device( mac, "any", delegate="", diff --git a/tests/components/bluetooth_le_tracker/test_device_tracker.py b/tests/components/bluetooth_le_tracker/test_device_tracker.py index c2f61888b86e..8dc31e2622e8 100644 --- a/tests/components/bluetooth_le_tracker/test_device_tracker.py +++ b/tests/components/bluetooth_le_tracker/test_device_tracker.py @@ -4,7 +4,6 @@ from datetime import timedelta from unittest.mock import patch from bleak import BleakError -from bleak.backends.scanner import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.bluetooth_le_tracker import device_tracker @@ -24,7 +23,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, slugify from tests.common import async_fire_time_changed -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device class MockBleakClient: @@ -89,7 +88,7 @@ async def test_preserve_new_tracked_device_name( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, @@ -114,7 +113,7 @@ async def test_preserve_new_tracked_device_name( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, @@ -159,7 +158,7 @@ async def test_tracking_battery_times_out( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, @@ -228,7 +227,7 @@ async def test_tracking_battery_fails( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=False, @@ -297,7 +296,7 @@ async def test_tracking_battery_successful( service_data={}, service_uuids=[], source="local", - device=BLEDevice(address, None), + device=generate_ble_device(address, None), advertisement=generate_advertisement_data(local_name="empty"), time=0, connectable=True, diff --git a/tests/components/bthome/__init__.py b/tests/components/bthome/__init__.py index d05c92b6902c..de46cd8231dc 100644 --- a/tests/components/bthome/__init__.py +++ b/tests/components/bthome/__init__.py @@ -1,15 +1,14 @@ """Tests for the BTHome integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device TEMP_HUMI_SERVICE_INFO = BluetoothServiceInfoBleak( name="ATC 8D18B2", address="A4:C1:38:8D:18:B2", - device=BLEDevice("A4:C1:38:8D:18:B2", None), + device=generate_ble_device("A4:C1:38:8D:18:B2", None), rssi=-63, manufacturer_data={}, service_data={ @@ -25,7 +24,7 @@ TEMP_HUMI_SERVICE_INFO = BluetoothServiceInfoBleak( TEMP_HUMI_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak( name="TEST DEVICE 8F80A5", address="54:48:E6:8F:80:A5", - device=BLEDevice("54:48:E6:8F:80:A5", None), + device=generate_ble_device("54:48:E6:8F:80:A5", None), rssi=-63, manufacturer_data={}, service_data={ @@ -43,7 +42,7 @@ TEMP_HUMI_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak( PRST_SERVICE_INFO = BluetoothServiceInfoBleak( name="prst 8F80A5", address="54:48:E6:8F:80:A5", - device=BLEDevice("54:48:E6:8F:80:A5", None), + device=generate_ble_device("54:48:E6:8F:80:A5", None), rssi=-63, manufacturer_data={}, service_data={ @@ -61,7 +60,7 @@ PRST_SERVICE_INFO = BluetoothServiceInfoBleak( INVALID_PAYLOAD = BluetoothServiceInfoBleak( name="ATC 565384", address="A4:C1:38:56:53:84", - device=BLEDevice("A4:C1:38:56:53:84", None), + device=generate_ble_device("A4:C1:38:56:53:84", None), rssi=-56, manufacturer_data={}, service_data={ @@ -77,7 +76,7 @@ INVALID_PAYLOAD = BluetoothServiceInfoBleak( NOT_BTHOME_SERVICE_INFO = BluetoothServiceInfoBleak( name="Not it", address="00:00:00:00:00:00", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-63, manufacturer_data={3234: b"\x00\x01"}, service_data={}, @@ -94,7 +93,7 @@ def make_bthome_v1_adv(address: str, payload: bytes) -> BluetoothServiceInfoBlea return BluetoothServiceInfoBleak( name="Test Device", address=address, - device=BLEDevice(address, None), + device=generate_ble_device(address, None), rssi=-56, manufacturer_data={}, service_data={ @@ -115,7 +114,7 @@ def make_encrypted_bthome_v1_adv( return BluetoothServiceInfoBleak( name="ATC 8F80A5", address=address, - device=BLEDevice(address, None), + device=generate_ble_device(address, None), rssi=-56, manufacturer_data={}, service_data={ @@ -134,7 +133,7 @@ def make_bthome_v2_adv(address: str, payload: bytes) -> BluetoothServiceInfoBlea return BluetoothServiceInfoBleak( name="Test Device", address=address, - device=BLEDevice(address, None), + device=generate_ble_device(address, None), rssi=-56, manufacturer_data={}, service_data={ diff --git a/tests/components/dormakaba_dkey/__init__.py b/tests/components/dormakaba_dkey/__init__.py index 12396a8c82b3..be51109b2a1b 100644 --- a/tests/components/dormakaba_dkey/__init__.py +++ b/tests/components/dormakaba_dkey/__init__.py @@ -1,9 +1,8 @@ """Tests for the Dormakaba dKey integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="00123456", @@ -13,7 +12,7 @@ DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=["e7a60000-6639-429f-94fd-86de8ea26897"], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:F0", name="00123456"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), advertisement=generate_advertisement_data( service_uuids=["e7a60000-6639-429f-94fd-86de8ea26897"] ), @@ -33,7 +32,7 @@ NOT_DKEY_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:F2", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:F2", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, diff --git a/tests/components/fjaraskupan/__init__.py b/tests/components/fjaraskupan/__init__.py index d4014ea8657a..5025fbeaf063 100644 --- a/tests/components/fjaraskupan/__init__.py +++ b/tests/components/fjaraskupan/__init__.py @@ -1,11 +1,9 @@ """Tests for the Fjäråskupan integration.""" -from bleak.backends.device import BLEDevice - from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device COOKER_SERVICE_INFO = BluetoothServiceInfoBleak( name="COOKERHOOD_FJAR", @@ -15,7 +13,7 @@ COOKER_SERVICE_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="COOKERHOOD_FJAR"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="COOKERHOOD_FJAR"), advertisement=generate_advertisement_data(), time=0, connectable=True, diff --git a/tests/components/ibeacon/__init__.py b/tests/components/ibeacon/__init__.py index 50636ee9d48c..a18a90f6c3dc 100644 --- a/tests/components/ibeacon/__init__.py +++ b/tests/components/ibeacon/__init__.py @@ -1,11 +1,11 @@ """Tests for the ibeacon integration.""" from typing import Any -from bleak.backends.device import BLEDevice - from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo -BLUECHARM_BLE_DEVICE = BLEDevice( +from tests.components.bluetooth import generate_ble_device + +BLUECHARM_BLE_DEVICE = generate_ble_device( address="61DE521B-F0BF-9F44-64D4-75BBE1738105", name="BlueCharm_177999", ) @@ -71,12 +71,12 @@ TESLA_TRANSIENT = BluetoothServiceInfo( service_uuids=[], source="hci0", ) -TESLA_TRANSIENT_BLE_DEVICE = BLEDevice( +TESLA_TRANSIENT_BLE_DEVICE = generate_ble_device( address="CC:CC:CC:CC:CC:CC", name="S6da7c9389bd5452cC", ) -FEASY_BEACON_BLE_DEVICE = BLEDevice( +FEASY_BEACON_BLE_DEVICE = generate_ble_device( address="AA:BB:CC:DD:EE:FF", name="FSC-BP108", ) diff --git a/tests/components/ibeacon/test_coordinator.py b/tests/components/ibeacon/test_coordinator.py index a13a3d2e7e68..3c9beaf396d6 100644 --- a/tests/components/ibeacon/test_coordinator.py +++ b/tests/components/ibeacon/test_coordinator.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from bleak.backends.scanner import BLEDevice import pytest from homeassistant.components.ibeacon.const import ATTR_SOURCE, DOMAIN, UPDATE_INTERVAL @@ -23,6 +22,7 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( generate_advertisement_data, + generate_ble_device, inject_advertisement_with_time_and_source_connectable, inject_bluetooth_service_info, patch_all_discovered_devices, @@ -276,7 +276,7 @@ async def test_changing_source_attribute(hass: HomeAssistant) -> None: now = time.monotonic() info = BLUECHARM_BEACON_SERVICE_INFO_2 - device = BLEDevice( + device = generate_ble_device( address=info.address, name=info.name, details={}, diff --git a/tests/components/keymitt_ble/__init__.py b/tests/components/keymitt_ble/__init__.py index 136ca99c56dc..2938e22c9240 100644 --- a/tests/components/keymitt_ble/__init__.py +++ b/tests/components/keymitt_ble/__init__.py @@ -1,12 +1,10 @@ """Tests for the MicroBot integration.""" from unittest.mock import patch -from bleak.backends.device import BLEDevice - from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.const import CONF_ADDRESS -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device DOMAIN = "keymitt_ble" @@ -44,7 +42,7 @@ SERVICE_INFO = BluetoothServiceInfoBleak( manufacturer_data={}, service_uuids=["0000abcd-0000-1000-8000-00805f9b34fb"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "mibp"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "mibp"), time=0, connectable=True, ) diff --git a/tests/components/ld2410_ble/__init__.py b/tests/components/ld2410_ble/__init__.py index 2abb955793da..b38115aab4df 100644 --- a/tests/components/ld2410_ble/__init__.py +++ b/tests/components/ld2410_ble/__init__.py @@ -1,9 +1,8 @@ """Tests for the LD2410 BLE Bluetooth integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device LD2410_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="HLK-LD2410B_EEFF", @@ -13,7 +12,7 @@ LD2410_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="HLK-LD2410B_EEFF"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="HLK-LD2410B_EEFF"), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -30,7 +29,7 @@ NOT_LD2410_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, diff --git a/tests/components/led_ble/__init__.py b/tests/components/led_ble/__init__.py index 7f48ff7a0876..10eaf7587575 100644 --- a/tests/components/led_ble/__init__.py +++ b/tests/components/led_ble/__init__.py @@ -1,9 +1,8 @@ """Tests for the LED BLE Bluetooth integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="Triones:F30200000152C", @@ -13,7 +12,9 @@ LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Triones:F30200000152C"), + device=generate_ble_device( + address="AA:BB:CC:DD:EE:FF", name="Triones:F30200000152C" + ), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -27,7 +28,9 @@ UNSUPPORTED_LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="LEDnetWFF30200000152C"), + device=generate_ble_device( + address="AA:BB:CC:DD:EE:FF", name="LEDnetWFF30200000152C" + ), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -45,7 +48,7 @@ NOT_LED_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 943018fae881..790301171cd4 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Generator from unittest.mock import AsyncMock, patch -from bleak.backends.device import BLEDevice from melnor_bluetooth.device import Device import pytest @@ -14,7 +13,7 @@ from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device FAKE_ADDRESS_1 = "FAKE-ADDRESS-1" FAKE_ADDRESS_2 = "FAKE-ADDRESS-2" @@ -30,7 +29,7 @@ FAKE_SERVICE_INFO_1 = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(FAKE_ADDRESS_1, None), + device=generate_ble_device(FAKE_ADDRESS_1, None), advertisement=generate_advertisement_data(local_name=""), time=0, connectable=True, @@ -46,7 +45,7 @@ FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(FAKE_ADDRESS_2, None), + device=generate_ble_device(FAKE_ADDRESS_2, None), advertisement=generate_advertisement_data(local_name=""), time=0, connectable=True, diff --git a/tests/components/oralb/__init__.py b/tests/components/oralb/__init__.py index d3f1b526fb8c..668f8804a5e5 100644 --- a/tests/components/oralb/__init__.py +++ b/tests/components/oralb/__init__.py @@ -1,11 +1,10 @@ """Tests for the OralB integration.""" -from bleak.backends.device import BLEDevice from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device NOT_ORALB_SERVICE_INFO = BluetoothServiceInfo( name="Not it", @@ -41,7 +40,7 @@ ORALB_IO_SERIES_4_SERVICE_INFO = BluetoothServiceInfo( ORALB_IO_SERIES_6_SERVICE_INFO = BluetoothServiceInfoBleak( name="Oral-B Toothbrush", address="B0:D2:78:20:1D:CF", - device=BLEDevice("B0:D2:78:20:1D:CF", "Oral-B Toothbrush"), + device=generate_ble_device("B0:D2:78:20:1D:CF", "Oral-B Toothbrush"), rssi=-56, manufacturer_data={220: b"\x062k\x02r\x00\x00\x02\x01\x00\x04"}, service_data={"a0f0ff00-5047-4d53-8208-4f72616c2d42": bytearray(b"1\x00\x00\x00")}, diff --git a/tests/components/snooz/__init__.py b/tests/components/snooz/__init__.py index d5802642c373..1e38978f447b 100644 --- a/tests/components/snooz/__init__.py +++ b/tests/components/snooz/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass from unittest.mock import patch -from bleak import BLEDevice from pysnooz.commands import SnoozCommandData from pysnooz.testing import MockSnoozDevice @@ -14,6 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_ble_device TEST_ADDRESS = "00:00:00:00:AB:CD" TEST_SNOOZ_LOCAL_NAME = "Snooz-ABCD" @@ -90,7 +90,7 @@ async def create_mock_snooz_config_entry( "homeassistant.components.snooz.SnoozDevice", return_value=device ), patch( "homeassistant.components.snooz.async_ble_device_from_address", - return_value=BLEDevice(device.address, device.name), + return_value=generate_ble_device(device.address, device.name), ): entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index ce39579915fc..257501ea1967 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1,14 +1,12 @@ """Tests for the switchbot integration.""" from unittest.mock import patch -from bleak.backends.device import BLEDevice - from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device DOMAIN = "switchbot" @@ -68,7 +66,7 @@ WOHAND_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("AA:BB:CC:DD:EE:FF", "WoHand"), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoHand"), time=0, connectable=True, ) @@ -88,7 +86,7 @@ WOHAND_SERVICE_INFO_NOT_CONNECTABLE = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoHand"), time=0, connectable=False, ) @@ -108,7 +106,7 @@ WOHAND_ENCRYPTED_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"\xc8\x10\xcf"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("798A8547-2A3D-C609-55FF-73FA824B923B", "WoHand"), + device=generate_ble_device("798A8547-2A3D-C609-55FF-73FA824B923B", "WoHand"), time=0, connectable=True, ) @@ -128,7 +126,7 @@ WOHAND_SERVICE_ALT_ADDRESS_INFO = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoHand"), time=0, connectable=True, ) @@ -146,7 +144,7 @@ WOCURTAIN_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Y\x00\x11\x04"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoCurtain"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoCurtain"), time=0, connectable=True, ) @@ -163,7 +161,7 @@ WOSENSORTH_SERVICE_INFO = BluetoothServiceInfoBleak( manufacturer_data={2409: b"\xda,\x1e\xb1\x86Au\x03\x00\x96\xac"}, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"}, ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoSensorTH"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoSensorTH"), time=0, connectable=False, ) @@ -183,7 +181,7 @@ WOLOCK_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"o\x80d"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoLock"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "WoLock"), time=0, connectable=True, ) @@ -200,7 +198,7 @@ NOT_SWITCHBOT_INFO = BluetoothServiceInfoBleak( manufacturer_data={}, service_data={}, ), - device=BLEDevice("aa:bb:cc:dd:ee:ff", "unknown"), + device=generate_ble_device("aa:bb:cc:dd:ee:ff", "unknown"), time=0, connectable=True, ) diff --git a/tests/components/xiaomi_ble/__init__.py b/tests/components/xiaomi_ble/__init__.py index 04fb1c03a449..ea11feab9c29 100644 --- a/tests/components/xiaomi_ble/__init__.py +++ b/tests/components/xiaomi_ble/__init__.py @@ -1,15 +1,14 @@ """Tests for the SensorPush integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfoBleak( name="Not it", address="00:00:00:00:00:00", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-63, manufacturer_data={3234: b"\x00\x01"}, service_data={}, @@ -23,7 +22,7 @@ NOT_SENSOR_PUSH_SERVICE_INFO = BluetoothServiceInfoBleak( LYWSDCGQ_SERVICE_INFO = BluetoothServiceInfoBleak( name="LYWSDCGQ", address="58:2D:34:35:93:21", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-63, manufacturer_data={}, service_data={ @@ -41,7 +40,7 @@ LYWSDCGQ_SERVICE_INFO = BluetoothServiceInfoBleak( MMC_T201_1_SERVICE_INFO = BluetoothServiceInfoBleak( name="MMC_T201_1", address="00:81:F9:DD:6F:C1", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={ @@ -59,7 +58,7 @@ MMC_T201_1_SERVICE_INFO = BluetoothServiceInfoBleak( JTYJGD03MI_SERVICE_INFO = BluetoothServiceInfoBleak( name="JTYJGD03MI", address="54:EF:44:E3:9C:BC", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={ @@ -77,7 +76,7 @@ JTYJGD03MI_SERVICE_INFO = BluetoothServiceInfoBleak( YLKG07YL_SERVICE_INFO = BluetoothServiceInfoBleak( name="YLKG07YL", address="F8:24:41:C5:98:8B", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={ @@ -95,7 +94,7 @@ YLKG07YL_SERVICE_INFO = BluetoothServiceInfoBleak( HHCCJCY10_SERVICE_INFO = BluetoothServiceInfoBleak( name="HHCCJCY10", address="DC:23:4D:E5:5B:FC", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={"0000fd50-0000-1000-8000-00805f9b34fb": b"\x0e\x00n\x014\xa4(\x00["}, @@ -109,7 +108,7 @@ HHCCJCY10_SERVICE_INFO = BluetoothServiceInfoBleak( MISSING_PAYLOAD_ENCRYPTED = BluetoothServiceInfoBleak( name="LYWSD02MMC", address="A4:C1:38:56:53:84", - device=BLEDevice("00:00:00:00:00:00", None), + device=generate_ble_device("00:00:00:00:00:00", None), rssi=-56, manufacturer_data={}, service_data={ @@ -130,7 +129,7 @@ def make_advertisement( return BluetoothServiceInfoBleak( name="Test Device", address=address, - device=BLEDevice(address, None), + device=generate_ble_device(address, None), rssi=-56, manufacturer_data={}, service_data={ diff --git a/tests/components/yalexs_ble/__init__.py b/tests/components/yalexs_ble/__init__.py index 200200c0a0bc..62a702f2f41e 100644 --- a/tests/components/yalexs_ble/__init__.py +++ b/tests/components/yalexs_ble/__init__.py @@ -1,9 +1,8 @@ """Tests for the Yale Access Bluetooth integration.""" -from bleak.backends.device import BLEDevice from homeassistant.components.bluetooth import BluetoothServiceInfoBleak -from tests.components.bluetooth import generate_advertisement_data +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device YALE_ACCESS_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="M1012LU", @@ -16,7 +15,7 @@ YALE_ACCESS_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -34,7 +33,7 @@ LOCK_DISCOVERY_INFO_UUID_ADDRESS = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="M1012LU"), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -51,7 +50,7 @@ OLD_FIRMWARE_LOCK_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, @@ -69,7 +68,7 @@ NOT_YALE_DISCOVERY_INFO = BluetoothServiceInfoBleak( service_uuids=[], service_data={}, source="local", - device=BLEDevice(address="AA:BB:CC:DD:EE:FF", name="Aug"), + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Aug"), advertisement=generate_advertisement_data(), time=0, connectable=True, From e258f36ded1570b8115a0a4c56cce0fa0e820d15 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 20 Mar 2023 12:06:40 +0100 Subject: [PATCH 0617/1058] Remove deprecated binary update sensor from AVM FRITZ!Box Tools (#89940) --- homeassistant/components/fritz/binary_sensor.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index d355906ec6eb..918a114fdf20 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -41,15 +41,6 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, ), - FritzBinarySensorEntityDescription( - # Deprecated, scheduled to be removed in 2022.7 (#70096) - entity_registry_enabled_default=False, - key="firmware_update", - name="Firmware Update", - device_class=BinarySensorDeviceClass.UPDATE, - entity_category=EntityCategory.DIAGNOSTIC, - is_suitable=lambda info: True, - ), ) @@ -89,13 +80,6 @@ class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): def update(self) -> None: """Update data.""" _LOGGER.debug("Updating FRITZ!Box binary sensors") - - if self.entity_description.key == "firmware_update": - self._attr_is_on = self._avm_wrapper.update_available - self._attr_extra_state_attributes = { - "installed_version": self._avm_wrapper.current_firmware, - "latest_available_version": self._avm_wrapper.latest_firmware, - } if self.entity_description.key == "is_connected": self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_connected) elif self.entity_description.key == "is_linked": From 146a31163cd6ff866bad3ab6f534e95ade58885d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 01:07:41 -1000 Subject: [PATCH 0618/1058] Use bluetooth address instead of uuid on MacOS (#89926) --- homeassistant/components/bluetooth/scanner.py | 6 ++- tests/components/bluetooth/test_scanner.py | 52 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index a80386c25ef4..911862a4221f 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -91,12 +91,16 @@ def create_bleak_scanner( "detection_callback": detection_callback, "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], } - if platform.system() == "Linux": + system = platform.system() + if system == "Linux": # Only Linux supports multiple adapters if adapter: scanner_kwargs["adapter"] = adapter if scanning_mode == BluetoothScanningMode.PASSIVE: scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS + elif system == "Darwin": + # We want mac address on macOS + scanner_kwargs["cb"] = {"use_bdaddr": True} _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) try: diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 81f9765de038..fcff8c15d58f 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -2,7 +2,7 @@ import asyncio from datetime import timedelta import time -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementDataCallback @@ -18,6 +18,7 @@ from homeassistant.components.bluetooth.scanner import NEED_RESET_ERRORS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( @@ -27,7 +28,7 @@ from . import ( generate_ble_device, ) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_config_entry_can_be_reloaded_when_stop_raises( @@ -580,3 +581,50 @@ async def test_restart_takes_longer_than_watchdog_time( await hass.async_block_till_done() assert "already restarting" in caplog.text + + +async def test_setup_and_stop_macos( + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None +) -> None: + """Test we enable use_bdaddr on MacOS.""" + entry = MockConfigEntry( + domain=bluetooth.DOMAIN, + data={}, + unique_id="00:00:00:00:00:00", + ) + entry.add_to_hass(hass) + init_kwargs = None + + class MockBleakScanner: + def __init__(self, *args, **kwargs): + """Init the scanner.""" + nonlocal init_kwargs + init_kwargs = kwargs + + async def start(self, *args, **kwargs): + """Start the scanner.""" + + async def stop(self, *args, **kwargs): + """Stop the scanner.""" + + def register_detection_callback(self, *args, **kwargs): + """Register a callback.""" + + with patch( + "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + MockBleakScanner, + ): + assert await async_setup_component( + hass, bluetooth.DOMAIN, {bluetooth.DOMAIN: {}} + ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert init_kwargs == { + "detection_callback": ANY, + "scanning_mode": "active", + "cb": {"use_bdaddr": True}, + } From 9f1e170851140fdf7946752ac9b972b4fb64cf7d Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 20 Mar 2023 12:08:27 +0100 Subject: [PATCH 0619/1058] Correct missing wordswap for S series nibe (#89866) Correct missing wordswap for nibe --- homeassistant/components/nibe_heatpump/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index fd77b5e23442..89aac6bed61d 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -62,13 +62,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nibe Heat Pump from a config entry.""" heatpump = HeatPump(Model[entry.data[CONF_MODEL]]) + heatpump.word_swap = entry.data.get(CONF_WORD_SWAP, True) await heatpump.initialize() connection: Connection connection_type = entry.data[CONF_CONNECTION_TYPE] if connection_type == CONF_CONNECTION_TYPE_NIBEGW: - heatpump.word_swap = entry.data[CONF_WORD_SWAP] connection = NibeGW( heatpump, entry.data[CONF_IP_ADDRESS], From 0bf652ca960358ba151118469686a1bf0221b331 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Mon, 20 Mar 2023 23:26:38 +1100 Subject: [PATCH 0620/1058] Refactor constants in geo_json_events integration (#89912) move constants to separate file --- .../components/geo_json_events/const.py | 15 +++++++++++++ .../geo_json_events/geo_location.py | 22 +++++++++---------- .../components/geo_json_events/manager.py | 6 ++--- .../geo_json_events/test_geo_location.py | 18 +++++++-------- 4 files changed, 38 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/geo_json_events/const.py diff --git a/homeassistant/components/geo_json_events/const.py b/homeassistant/components/geo_json_events/const.py new file mode 100644 index 000000000000..4c73be3995ec --- /dev/null +++ b/homeassistant/components/geo_json_events/const.py @@ -0,0 +1,15 @@ +"""Define constants for the GeoJSON events integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +DOMAIN: Final = "geo_json_events" + +ATTR_EXTERNAL_ID: Final = "external_id" +DEFAULT_RADIUS_IN_KM: Final = 20.0 +DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=5) +SOURCE: Final = "geo_json_events" + +SIGNAL_DELETE_ENTITY: Final = "geo_json_events_delete_{}" +SIGNAL_UPDATE_ENTITY: Final = "geo_json_events_update_{}" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 2df049dd9cd2..df2978b654e4 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -26,18 +26,18 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import ( + ATTR_EXTERNAL_ID, + DEFAULT_RADIUS_IN_KM, + DEFAULT_SCAN_INTERVAL, + SIGNAL_DELETE_ENTITY, + SIGNAL_UPDATE_ENTITY, + SOURCE, +) from .manager import GeoJsonFeedEntityManager _LOGGER = logging.getLogger(__name__) -ATTR_EXTERNAL_ID = "external_id" - -DEFAULT_RADIUS_IN_KM = 20.0 - -SCAN_INTERVAL = timedelta(minutes=5) - -SOURCE = "geo_json_events" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): cv.string, @@ -56,7 +56,7 @@ async def async_setup_platform( ) -> None: """Set up the GeoJSON Events platform.""" url: str = config[CONF_URL] - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) coordinates: tuple[float, float] = ( config.get(CONF_LATITUDE, hass.config.latitude), config.get(CONF_LONGITUDE, hass.config.longitude), @@ -106,12 +106,12 @@ class GeoJsonLocationEvent(GeolocationEvent): """Call when entity is added to hass.""" self._remove_signal_delete = async_dispatcher_connect( self.hass, - f"geo_json_events_delete_{self._external_id}", + SIGNAL_DELETE_ENTITY.format(self._external_id), self._delete_callback, ) self._remove_signal_update = async_dispatcher_connect( self.hass, - f"geo_json_events_update_{self._external_id}", + SIGNAL_UPDATE_ENTITY.format(self._external_id), self._update_callback, ) diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py index 6c51e6dd7235..a999d224ac78 100644 --- a/homeassistant/components/geo_json_events/manager.py +++ b/homeassistant/components/geo_json_events/manager.py @@ -12,7 +12,7 @@ from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -DOMAIN = "geo_json_events" +from .const import DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY _LOGGER = logging.getLogger(__name__) @@ -77,8 +77,8 @@ class GeoJsonFeedEntityManager: async def _update_entity(self, external_id: str) -> None: """Update entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) async def _remove_entity(self, external_id: str) -> None: """Remove entity.""" - async_dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") + async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index b79c5f150468..529d78fd83ca 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -5,9 +5,9 @@ from aio_geojson_generic_client import GenericFeed from freezegun import freeze_time from homeassistant.components import geo_location -from homeassistant.components.geo_json_events.geo_location import ( +from homeassistant.components.geo_json_events.const import ( ATTR_EXTERNAL_ID, - SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, ) from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.const import ( @@ -132,7 +132,7 @@ async def test_setup(hass: HomeAssistant) -> None: "OK", [mock_entry_1, mock_entry_4, mock_entry_3], ) - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -141,7 +141,7 @@ async def test_setup(hass: HomeAssistant) -> None: # Simulate an update - empty data, but successful update, # so no changes to entities. mock_feed_update.return_value = "OK_NO_DATA", None - async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -149,7 +149,7 @@ async def test_setup(hass: HomeAssistant) -> None: # Simulate an update - empty data, removes all entities mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -227,7 +227,7 @@ async def test_setup_race_condition(hass: HomeAssistant) -> None: # Simulate an update - empty data, removes all entities mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -237,7 +237,7 @@ async def test_setup_race_condition(hass: HomeAssistant) -> None: # Simulate an update - 1 entry mock_feed_update.return_value = "OK", [mock_entry_1] - async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -247,7 +247,7 @@ async def test_setup_race_condition(hass: HomeAssistant) -> None: # Simulate an update - 1 entry mock_feed_update.return_value = "OK", [mock_entry_1] - async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() @@ -257,7 +257,7 @@ async def test_setup_race_condition(hass: HomeAssistant) -> None: # Simulate an update - empty data, removes all entities mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + 4 * SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 4 * DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() all_states = hass.states.async_all() From b9ff69d3ac8e9fd8353c10d553cba588dc222266 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Mar 2023 13:39:20 +0100 Subject: [PATCH 0621/1058] Extend attribute state translations for Camera (#89876) * Extend attribute state translations for Camera * Add common generic translations --- homeassistant/components/camera/strings.json | 25 ++++++++++++++++++++ homeassistant/strings.json | 3 +++ 2 files changed, 28 insertions(+) diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 06ddaeeb092b..0722ec1c5e6d 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -7,6 +7,31 @@ "recording": "Recording", "streaming": "Streaming", "idle": "[%key:common::state::idle%]" + }, + "state_attributes": { + "access_token": { + "name": "Access token" + }, + "brand": { + "name": "Brand" + }, + "frontend_stream_type": { + "name": "Stream type", + "state": { + "hls": "HLS", + "webrtc": "WebRTC" + } + }, + "motion_detection": { + "name": "Motion detection", + "state": { + "true": "Enabled", + "false": "Disabled" + } + }, + "model_name": { + "name": "[%key:common::generic::model%]" + } } } } diff --git a/homeassistant/strings.json b/homeassistant/strings.json index f3829cd2bd9e..ad18b675e073 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -1,5 +1,8 @@ { "common": { + "generic": { + "model": "Model" + }, "state": { "off": "Off", "on": "On", From 9949ca13aa7d04860774e41d8ec3353b5830504b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Mar 2023 13:42:59 +0100 Subject: [PATCH 0622/1058] Adjust state class of Toon monetary sensors (#89985) --- homeassistant/components/toon/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 3b06f5d38b99..90dd466045cc 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -183,7 +183,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( section="gas_usage", measurement="day_cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_EUR, icon="mdi:gas-cylinder", cls=ToonGasMeterDeviceSensor, @@ -233,7 +233,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( section="power_usage", measurement="day_cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_EUR, icon="mdi:power-plug", cls=ToonElectricityMeterDeviceSensor, @@ -358,7 +358,7 @@ SENSOR_ENTITIES: tuple[ToonSensorEntityDescription, ...] = ( section="water_usage", measurement="day_cost", device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_EUR, icon="mdi:water-pump", entity_registry_enabled_default=False, From 9a5ceb9ef832f1ca4e95fc00708ae24db3331707 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 20 Mar 2023 15:44:05 +0100 Subject: [PATCH 0623/1058] Use default rounding/presentation mechanism for upnp (#89954) --- homeassistant/components/upnp/coordinator.py | 11 +++++---- homeassistant/components/upnp/device.py | 7 ++++-- homeassistant/components/upnp/entity.py | 1 - homeassistant/components/upnp/sensor.py | 25 ++++++++++---------- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/upnp/coordinator.py b/homeassistant/components/upnp/coordinator.py index 2820a5846324..72e14ecc4ffa 100644 --- a/homeassistant/components/upnp/coordinator.py +++ b/homeassistant/components/upnp/coordinator.py @@ -1,7 +1,6 @@ """UPnP/IGD coordinator.""" -from datetime import timedelta -from typing import Any +from datetime import datetime, timedelta from async_upnp_client.exceptions import UpnpCommunicationError @@ -13,7 +12,9 @@ from .const import LOGGER from .device import Device -class UpnpDataUpdateCoordinator(DataUpdateCoordinator): +class UpnpDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, str | datetime | int | float | None]] +): """Define an object to update data from UPNP device.""" def __init__( @@ -34,7 +35,9 @@ class UpnpDataUpdateCoordinator(DataUpdateCoordinator): update_interval=update_interval, ) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data( + self, + ) -> dict[str, str | datetime | int | float | None]: """Update data.""" try: return await self.device.async_get_data() diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index ed06a9eb3631..b62edbf9bc22 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -1,6 +1,7 @@ """Home Assistant representation of an UPnP/IGD.""" from __future__ import annotations +from datetime import datetime from functools import partial from ipaddress import ip_address from typing import Any @@ -68,7 +69,9 @@ class Device: """Initialize UPnP/IGD device.""" self.hass = hass self._igd_device = igd_device - self.coordinator: DataUpdateCoordinator | None = None + self.coordinator: DataUpdateCoordinator[ + dict[str, str | datetime | int | float | None] + ] | None = None self.original_udn: str | None = None async def async_get_mac_address(self) -> str | None: @@ -134,7 +137,7 @@ class Device: """Get string representation.""" return f"IGD Device: {self.name}/{self.udn}::{self.device_type}" - async def async_get_data(self) -> dict[str, Any]: + async def async_get_data(self) -> dict[str, str | datetime | int | float | None]: """Get all data from device.""" _LOGGER.debug("Getting data for device: %s", self) igd_state = await self._igd_device.async_get_traffic_and_status_data() diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index b787018adcc9..cd39609d9d51 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -13,7 +13,6 @@ from .coordinator import UpnpDataUpdateCoordinator class UpnpEntityDescription(EntityDescription): """UPnP entity description.""" - format: str = "s" unique_id: str | None = None value_key: str | None = None diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 1a374714be87..6f0fe340f304 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from homeassistant.components.sensor import ( SensorDeviceClass, @@ -52,9 +53,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=BYTES_SENT, @@ -62,27 +63,27 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, name=f"{DATA_PACKETS} received", icon="mdi:server-network", native_unit_of_measurement=DATA_PACKETS, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=PACKETS_SENT, name=f"{DATA_PACKETS} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_PACKETS, - format="d", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=ROUTER_IP, @@ -96,8 +97,8 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, - format="d", entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, ), UpnpSensorEntityDescription( key=WAN_STATUS, @@ -114,8 +115,8 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - format=".1f", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), UpnpSensorEntityDescription( key=BYTES_SENT, @@ -125,8 +126,8 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( icon="mdi:server-network", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - format=".1f", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, @@ -135,9 +136,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( name=f"{DATA_RATE_PACKETS_PER_SECOND} received", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, - format=".1f", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), UpnpSensorEntityDescription( key=PACKETS_SENT, @@ -146,9 +147,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, - format=".1f", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), ) @@ -180,10 +181,8 @@ class UpnpSensor(UpnpEntity, SensorEntity): entity_description: UpnpSensorEntityDescription @property - def native_value(self) -> str | None: + def native_value(self) -> str | datetime | int | float | None: """Return the state of the device.""" if (key := self.entity_description.value_key) is None: return None - if (value := self.coordinator.data[key]) is None: - return None - return format(value, self.entity_description.format) + return self.coordinator.data[key] From e4275a053c9c681c49363d0adf6a07a085f98a5a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Mar 2023 15:52:07 +0100 Subject: [PATCH 0624/1058] Remove yaml import from imap integration (#89981) * Remove yaml import from imap integration * Cleanup sensor code and strings.json --- homeassistant/components/imap/config_flow.py | 9 +-- homeassistant/components/imap/sensor.py | 58 ++------------------ homeassistant/components/imap/strings.json | 6 -- tests/components/imap/test_config_flow.py | 43 --------------- 4 files changed, 6 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 7306d07d06a5..36528ae6ed9b 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -9,7 +9,7 @@ from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -87,8 +87,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) if not (errors := await validate_input(user_input)): - # To be removed when YAML import is removed - title = user_input.get(CONF_NAME, user_input[CONF_USERNAME]) + title = user_input[CONF_USERNAME] return self.async_create_entry(title=title, data=user_input) @@ -96,10 +95,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) - 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( diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 0bccce0c98dc..4dc0c0fffbe2 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -1,67 +1,17 @@ """IMAP sensor support.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME, CONF_USERNAME from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import 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 . import ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator -from .const import ( - CONF_CHARSET, - CONF_FOLDER, - CONF_SEARCH, - CONF_SERVER, - DEFAULT_PORT, - DOMAIN, -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_SERVER): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_CHARSET, default="utf-8"): cv.string, - vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, - vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the IMAP platform.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.4.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) +from .const import DOMAIN async def async_setup_entry( diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 25bcf840c334..2fedef55f61d 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -30,11 +30,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The IMAP YAML configuration is being removed", - "description": "Configuring IMAP using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the IMAP YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 7fc5f998843e..ad8e63fa6171 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -59,49 +59,6 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - mock_client.return_value.search.return_value = ( - "OK", - [b""], - ) - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": "IMAP", - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "charset": "utf-8", - "folder": "INBOX", - "search": "UnSeen UnDeleted", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "IMAP" - assert result2["data"] == { - "name": "IMAP", - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "charset": "utf-8", - "folder": "INBOX", - "search": "UnSeen UnDeleted", - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_entry_already_configured(hass: HomeAssistant) -> None: """Test aborting if the entry is already configured.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) From 51b12cbf960bffc4ee9eced9a58bdd45da67e617 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Mar 2023 15:52:54 +0100 Subject: [PATCH 0625/1058] Add user_input as suggested value in imap config flow (#89982) Add user_input as suggested value to config_schema --- homeassistant/components/imap/config_flow.py | 5 ++--- tests/components/imap/test_config_flow.py | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 36528ae6ed9b..de1ac1e5d659 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -91,9 +91,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=user_input) - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) + schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index ad8e63fa6171..663637ff0ba8 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -129,6 +129,12 @@ async def test_form_cannot_connect(hass: HomeAssistant, exc: Exception) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + # make sure we do not lose the user input if somethings gets wrong + assert { + key: key.description.get("suggested_value") + for key in result2["data_schema"].schema + } == MOCK_CONFIG + async def test_form_invalid_charset(hass: HomeAssistant) -> None: """Test we handle invalid charset.""" From 6bb80adbb9079e1304062bbc565a3be84a076275 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 06:15:11 -1000 Subject: [PATCH 0626/1058] Rollback the session after performing stats schema validation (#89904) --- homeassistant/components/recorder/statistics.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 989dc06db57c..34adcbddcc65 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2504,7 +2504,9 @@ def _validate_db_schema_utf8( # Try inserting some metadata which needs utfmb4 support try: - with session_scope(session=session_maker()) as session: + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=session_maker(), read_only=True) as session: old_metadata_dict = statistics_meta_manager.get_many( session, statistic_ids={statistic_id} ) @@ -2605,7 +2607,9 @@ def _validate_db_schema( StatisticsShortTerm, ) try: - with session_scope(session=session_maker()) as session: + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=session_maker(), read_only=True) as session: for table in tables: _import_statistics_with_session( instance, session, metadata, (statistics,), table From 20c9ed6d892ad703148d902fe423dd4a623bd5f2 Mon Sep 17 00:00:00 2001 From: Jon Caruana Date: Mon, 20 Mar 2023 12:01:14 -0700 Subject: [PATCH 0627/1058] Mark LiteJet as Platinum integration (#88623) --- homeassistant/components/litejet/manifest.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index b2b213d06f5f..136880257ce2 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -4,7 +4,9 @@ "codeowners": ["@joncar"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litejet", + "integration_type": "hub", "iot_class": "local_push", "loggers": ["pylitejet"], + "quality_scale": "platinum", "requirements": ["pylitejet==0.5.0"] } From 49f08ad71d2db803675def882118d773462cf81e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 09:04:46 -1000 Subject: [PATCH 0628/1058] Filter out duplicate updates in esphome state dispatch (#89779) --- homeassistant/components/esphome/__init__.py | 6 +++++- .../components/esphome/entry_data.py | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 59db885d4508..192a19e480be 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -345,6 +345,10 @@ async def async_setup_entry( # noqa: C901 disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False + # Clear out the states so that we will always dispatch + # the next state update of that type when the device reconnects + for state_keys in entry_data.state.values(): + state_keys.clear() entry_data.async_update_device_state(hass) async def on_connect_error(err: Exception) -> None: @@ -760,7 +764,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self.async_on_remove( async_dispatcher_connect( self.hass, - f"esphome_{self._entry_id}_on_device_update", + self._entry_data.signal_device_updated, self._on_device_update, ) ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 0aed6ce43a7d..4d035427085e 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -39,6 +39,7 @@ from homeassistant.helpers.storage import Store from .dashboard import async_get_dashboard +_SENTINEL = object() SAVE_DELAY = 120 _LOGGER = logging.getLogger(__name__) @@ -198,14 +199,26 @@ class RuntimeEntryData: @callback def async_update_state(self, state: EntityState) -> None: """Distribute an update of state information to the target.""" - subscription_key = (type(state), state.key) - self.state[type(state)][state.key] = state + key = state.key + state_type = type(state) + current_state_by_type = self.state[state_type] + current_state = current_state_by_type.get(key, _SENTINEL) + if current_state == state: + _LOGGER.debug( + "%s: ignoring duplicate update with and key %s: %s", + self.name, + key, + state, + ) + return _LOGGER.debug( "%s: dispatching update with key %s: %s", self.name, - subscription_key, + key, state, ) + current_state_by_type[key] = state + subscription_key = (state_type, key) if subscription_key in self.state_subscriptions: self.state_subscriptions[subscription_key]() From 18df3a22cae94347d5608f0370161ba53e8a2f57 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Mar 2023 20:06:44 +0100 Subject: [PATCH 0629/1058] Add FTTH information to SFR Box (#89781) --- homeassistant/components/sfr_box/__init__.py | 5 +- .../components/sfr_box/binary_sensor.py | 18 ++- homeassistant/components/sfr_box/models.py | 3 +- .../sfr_box/snapshots/test_binary_sensor.ambr | 123 +++++++++++++++++- .../components/sfr_box/test_binary_sensor.py | 9 +- 5 files changed, 149 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index b4014c159adc..564f1970b640 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -36,6 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = DomainData( box=box, dsl=SFRDataUpdateCoordinator(hass, box, "dsl", lambda b: b.dsl_get_info()), + ftth=SFRDataUpdateCoordinator(hass, box, "ftth", lambda b: b.ftth_get_info()), system=SFRDataUpdateCoordinator( hass, box, "system", lambda b: b.system_get_info() ), @@ -47,8 +48,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Preload other coordinators (based on net infrastructure) tasks = [data.wan.async_config_entry_first_refresh()] - if system_info.net_infra == "adsl": + if (net_infra := system_info.net_infra) == "adsl": tasks.append(data.dsl.async_config_entry_first_refresh()) + elif net_infra == "ftth": + tasks.append(data.ftth.async_config_entry_first_refresh()) await asyncio.gather(*tasks) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 83c7cd8d1062..8758764a14c2 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Generic, TypeVar -from sfrbox_api.models import DslInfo, SystemInfo, WanInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -48,6 +48,15 @@ DSL_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[DslInfo], ...] = ( value_fn=lambda x: x.status == "up", ), ) +FTTH_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[FtthInfo], ...] = ( + SFRBoxBinarySensorEntityDescription[FtthInfo]( + key="status", + name="FTTH status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda x: x.status == "up", + ), +) WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = ( SFRBoxBinarySensorEntityDescription[WanInfo]( key="status", @@ -69,11 +78,16 @@ async def async_setup_entry( SFRBoxBinarySensor(data.wan, description, data.system.data) for description in WAN_SENSOR_TYPES ] - if data.system.data.net_infra == "adsl": + if (net_infra := data.system.data.net_infra) == "adsl": entities.extend( SFRBoxBinarySensor(data.dsl, description, data.system.data) for description in DSL_SENSOR_TYPES ) + elif net_infra == "ftth": + entities.extend( + SFRBoxBinarySensor(data.ftth, description, data.system.data) + for description in FTTH_SENSOR_TYPES + ) async_add_entities(entities) diff --git a/homeassistant/components/sfr_box/models.py b/homeassistant/components/sfr_box/models.py index 9302de83e773..ff723c2c6efa 100644 --- a/homeassistant/components/sfr_box/models.py +++ b/homeassistant/components/sfr_box/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from sfrbox_api.bridge import SFRBox -from sfrbox_api.models import DslInfo, SystemInfo, WanInfo +from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo from .coordinator import SFRDataUpdateCoordinator @@ -13,5 +13,6 @@ class DomainData: box: SFRBox dsl: SFRDataUpdateCoordinator[DslInfo] + ftth: SFRDataUpdateCoordinator[FtthInfo] system: SFRDataUpdateCoordinator[SystemInfo] wan: SFRDataUpdateCoordinator[WanInfo] diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index a932a3beae32..dcb3508ace24 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensors +# name: test_binary_sensors[adsl] list([ DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -28,7 +28,7 @@ }), ]) # --- -# name: test_binary_sensors.1 +# name: test_binary_sensors[adsl].1 list([ EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -88,7 +88,7 @@ }), ]) # --- -# name: test_binary_sensors[binary_sensor.sfr_box_dsl_status] +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_dsl_status] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -101,7 +101,122 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[binary_sensor.sfr_box_wan_status] +# name: test_binary_sensors[adsl][binary_sensor.sfr_box_wan_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box WAN status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_wan_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensors[ftth] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.0.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'sfr_box', + 'e4:5d:51:00:11:22', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'NB6VAC-FXC-r0', + 'name': 'SFR Box', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'NB6VAC-MAIN-R4.0.44k', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[ftth].1 + list([ + 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.sfr_box_wan_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'WAN status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_wan_status', + 'unit_of_measurement': None, + }), + 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.sfr_box_ftth_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'FTTH status', + 'platform': 'sfr_box', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'e4:5d:51:00:11:22_ftth_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_ftth_status] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'SFR Box FTTH status', + }), + 'context': , + 'entity_id': 'binary_sensor.sfr_box_ftth_status', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[ftth][binary_sensor.sfr_box_wan_status] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/sfr_box/test_binary_sensor.py b/tests/components/sfr_box/test_binary_sensor.py index c7a643a91687..db6124bec3da 100644 --- a/tests/components/sfr_box/test_binary_sensor.py +++ b/tests/components/sfr_box/test_binary_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from sfrbox_api.models import SystemInfo from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -10,7 +11,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -pytestmark = pytest.mark.usefixtures("system_get_info", "dsl_get_info", "wan_get_info") +pytestmark = pytest.mark.usefixtures( + "system_get_info", "dsl_get_info", "ftth_get_info", "wan_get_info" +) @pytest.fixture(autouse=True) @@ -20,14 +23,18 @@ def override_platforms() -> Generator[None, None, None]: yield +@pytest.mark.parametrize("net_infra", ["adsl", "ftth"]) async def test_binary_sensors( hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + system_get_info: SystemInfo, + net_infra: str, ) -> None: """Test for SFR Box binary sensors.""" + system_get_info.net_infra = net_infra await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From cbe85126cb8e01d99037eb69e852f6bb965551f4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 20 Mar 2023 18:30:56 -0400 Subject: [PATCH 0630/1058] Introduce a delay between update entity calls (#89737) * Introduce a delay between update entity calls * Update homeassistant/components/zwave_js/update.py Co-authored-by: Martin Hjelmare * move delay to constant and patch * rename constant * Switch to async_call_later * Remove failing test * Reimplement to solve task problem * comment * pass count directly so that value doesn't mutate before we store it * lines * Fix logic and tests * Comments * Readd missed coverage * Add test for delays * cleanup * Fix async_added_to_hass logic * flip conditional * Store firmware info in extra data so we can restore it along with latest version * Comment * comment * Add test for is_running check and fix bugs * comment * Add tests for various restore state scenarios * move comment so it's less confusing * improve typing * consolidate into constant and remove unused one * Update update.py * update test to unknown state during partial restore * fix elif check * Fix type * clean up test docstrings and function names --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/update.py | 101 +++++-- tests/components/zwave_js/conftest.py | 6 + tests/components/zwave_js/test_update.py | 296 +++++++++++++++++--- 3 files changed, 350 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 5485870dc5f2..33cb0a1c5a8b 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio +from collections import Counter from collections.abc import Callable +from dataclasses import asdict, dataclass from datetime import datetime, timedelta -from typing import Any +from typing import Any, Final from awesomeversion import AwesomeVersion from zwave_js_server.client import Client as ZwaveClient @@ -19,41 +21,72 @@ from zwave_js_server.model.node.firmware import ( ) from homeassistant.components.update import ( + ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CoreState, 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.event import async_call_later -from homeassistant.helpers.start import async_at_start +from homeassistant.helpers.restore_state import ExtraStoredData from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DATA_CLIENT, DOMAIN, LOGGER from .helpers import get_device_info, get_valueless_base_unique_id PARALLEL_UPDATES = 1 +UPDATE_DELAY_STRING = "delay" +UPDATE_DELAY_INTERVAL = 5 # In minutes + + +@dataclass +class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): + """Extra stored data for Z-Wave node firmware update entity.""" + + latest_version_firmware: NodeFirmwareUpdateInfo | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the extra data.""" + return { + "latest_version_firmware": asdict(self.latest_version_firmware) + if self.latest_version_firmware + else None + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: + """Initialize the extra data from a dict.""" + if not (firmware_dict := data["latest_version_firmware"]): + return cls(None) + + return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Z-Wave button from config entry.""" + """Set up Z-Wave update entity from config entry.""" client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - - semaphore = asyncio.Semaphore(3) + cnt: Counter = Counter() @callback def async_add_firmware_update_entity(node: ZwaveNode) -> None: """Add firmware update entity.""" + # We need to delay the first update of each entity to avoid flooding the network + # so we maintain a counter to schedule first update in UPDATE_DELAY_INTERVAL + # minute increments. + cnt[UPDATE_DELAY_STRING] += 1 + delay = timedelta(minutes=(cnt[UPDATE_DELAY_STRING] * UPDATE_DELAY_INTERVAL)) driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, semaphore)]) + async_add_entities([ZWaveNodeFirmwareUpdate(driver, node, delay)]) config_entry.async_on_unload( async_dispatcher_connect( @@ -77,13 +110,10 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): _attr_has_entity_name = True _attr_should_poll = False - def __init__( - self, driver: Driver, node: ZwaveNode, semaphore: asyncio.Semaphore - ) -> None: + def __init__(self, driver: Driver, node: ZwaveNode, delay: timedelta) -> None: """Initialize a Z-Wave device firmware update entity.""" self.driver = driver self.node = node - self.semaphore = semaphore self._latest_version_firmware: NodeFirmwareUpdateInfo | None = None self._status_unsub: Callable[[], None] | None = None self._poll_unsub: Callable[[], None] | None = None @@ -91,6 +121,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._finished_unsub: Callable[[], None] | None = None self._finished_event = asyncio.Event() self._result: NodeFirmwareUpdateResult | None = None + self._delay: Final[timedelta] = delay # Entity class attributes self._attr_name = "Firmware" @@ -100,6 +131,11 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): # device may not be precreated in main handler yet self._attr_device_info = get_device_info(driver, node) + @property + def extra_restore_state_data(self) -> ExtraStoredData: + """Return ZWave Node Firmware Update specific state data to be restored.""" + return ZWaveNodeFirmwareUpdateExtraStoredData(self._latest_version_firmware) + @callback def _update_on_status_change(self, _: dict[str, Any]) -> None: """Update the entity when node is awake.""" @@ -143,7 +179,17 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): async def _async_update(self, _: HomeAssistant | datetime | None = None) -> None: """Update the entity.""" - self._poll_unsub = None + if self._poll_unsub: + self._poll_unsub() + self._poll_unsub = None + + # 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: + self._poll_unsub = async_call_later( + self.hass, timedelta(days=1), self._async_update + ) + return # If device is asleep/dead, wait for it to wake up/become alive before # attempting an update @@ -159,12 +205,11 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): return try: - async with self.semaphore: - available_firmware_updates = ( - await self.driver.controller.async_get_available_firmware_updates( - self.node, API_KEY_FIRMWARE_UPDATE_SERVICE - ) + available_firmware_updates = ( + await self.driver.controller.async_get_available_firmware_updates( + self.node, API_KEY_FIRMWARE_UPDATE_SERVICE ) + ) except FailedZWaveCommand as err: LOGGER.debug( "Failed to get firmware updates for node %s: %s", @@ -277,7 +322,27 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) ) - self.async_on_remove(async_at_start(self.hass, self._async_update)) + # If we have a complete previous state, use that to set the latest version + if (state := await self.async_get_last_state()) and ( + extra_data := await self.async_get_last_extra_data() + ): + self._attr_latest_version = state.attributes[ATTR_LATEST_VERSION] + self._latest_version_firmware = ( + ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( + extra_data.as_dict() + ).latest_version_firmware + ) + # If we have no state to restore, we can set the latest version to installed + # so that the entity starts as off. If we have partial restore data due to an + # upgrade to an HA version where this feature is released from one that is not + # the entity will start in an unknown state until we can correct on next update + elif not state: + self._attr_latest_version = self._attr_installed_version + + # Spread updates out in 5 minute increments to avoid flooding the network + self.async_on_remove( + async_call_later(self.hass, self._delay, self._async_update) + ) async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed.""" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index f20c814bdc33..32082a0bb858 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -235,6 +235,9 @@ def create_backup_fixture(): yield create_backup +# State fixtures + + @pytest.fixture(name="controller_state", scope="session") def controller_state_fixture(): """Load the controller state fixture data.""" @@ -601,6 +604,9 @@ def lock_home_connect_620_state_fixture(): return json.loads(load_fixture("zwave_js/lock_home_connect_620_state.json")) +# model fixtures + + @pytest.fixture(name="client") def mock_client_fixture( controller_state, controller_node_state, version_state, log_config_state diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 30371904a471..36f16d0b5025 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -20,16 +20,26 @@ from homeassistant.components.update import ( ) from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache, + mock_restore_cache_with_extra_data, +) from tests.typing import WebSocketGenerator UPDATE_ENTITY = "update.z_wave_thermostat_firmware" +LATEST_VERSION_FIRMWARE = { + "version": "11.2.4", + "changelog": "blah 2", + "files": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], +} FIRMWARE_UPDATES = { "updates": [ { @@ -39,13 +49,7 @@ FIRMWARE_UPDATES = { {"target": 0, "url": "https://example1.com", "integrity": "sha1"} ], }, - { - "version": "11.2.4", - "changelog": "blah 2", - "files": [ - {"target": 0, "url": "https://example2.com", "integrity": "sha2"} - ], - }, + LATEST_VERSION_FIRMWARE, { "version": "11.1.5", "changelog": "blah 3", @@ -56,19 +60,6 @@ FIRMWARE_UPDATES = { ] } -FIRMWARE_UPDATE_MULTIPLE_FILES = { - "updates": [ - { - "version": "11.2.4", - "changelog": "blah 2", - "files": [ - {"target": 0, "url": "https://example2.com", "integrity": "sha2"}, - {"target": 1, "url": "https://example4.com", "integrity": "sha4"}, - ], - }, - ] -} - async def test_update_entity_states( hass: HomeAssistant, @@ -85,7 +76,7 @@ async def test_update_entity_states( client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -104,7 +95,7 @@ async def test_update_entity_states( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -139,6 +130,15 @@ async def test_update_entity_states( assert "There is no value to refresh for this entity" in caplog.text + client.async_send_command.return_value = {"updates": []} + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=3)) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + # Assert a node firmware update entity is not created for the controller driver = client.driver node = driver.controller.nodes[1] @@ -164,7 +164,7 @@ async def test_update_entity_install_raises( """Test update entity install raises exception.""" client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() # Test failed installation by driver @@ -197,7 +197,7 @@ async def test_update_entity_sleep( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() # Because node is asleep we shouldn't attempt to check for firmware updates @@ -234,7 +234,7 @@ async def test_update_entity_dead( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() # Because node is asleep we shouldn't attempt to check for firmware updates @@ -261,7 +261,7 @@ async def test_update_entity_ha_not_running( zen_31, hass_ws_client: WebSocketGenerator, ) -> None: - """Test update occurs after HA starts.""" + """Test update occurs only after HA is running.""" await hass.async_stop() entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) @@ -272,6 +272,22 @@ async def test_update_entity_ha_not_running( assert len(client.async_send_command.call_args_list) == 0 await hass.async_start() + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 0 + + # Update should be delayed by a day because HA is not running + hass.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) == 0 + + hass.state = CoreState.running + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) + await hass.async_block_till_done() assert len(client.async_send_command.call_args_list) == 1 args = client.async_send_command.call_args_list[0][0][0] @@ -289,7 +305,7 @@ async def test_update_entity_update_failure( assert len(client.async_send_command.call_args_list) == 0 client.async_send_command.side_effect = FailedZWaveCommand("test", 260, "test") - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -314,7 +330,7 @@ async def test_update_entity_progress( node = climate_radio_thermostat_ct100_plus_different_endpoints client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -410,7 +426,7 @@ async def test_update_entity_install_failed( node = climate_radio_thermostat_ct100_plus_different_endpoints client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -503,7 +519,7 @@ async def test_update_entity_reload( client.async_send_command.return_value = {"updates": []} - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -512,7 +528,7 @@ async def test_update_entity_reload( client.async_send_command.return_value = FIRMWARE_UPDATES - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=2)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) @@ -543,10 +559,220 @@ async def test_update_entity_reload( await hass.async_block_till_done() # Trigger another update and make sure the skipped version is still skipped - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=4)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=4)) await hass.async_block_till_done() state = hass.states.get(UPDATE_ENTITY) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" + + +async def test_update_entity_delay( + hass: HomeAssistant, + client, + ge_in_wall_dimmer_switch, + zen_31, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test update occurs on a delay after HA starts.""" + client.async_send_command.reset_mock() + await hass.async_stop() + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 0 + + await hass.async_start() + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 0 + + 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 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == ge_in_wall_dimmer_switch.node_id + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "controller.get_available_firmware_updates" + assert args["nodeId"] == zen_31.node_id + + +async def test_update_entity_partial_restore_data( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test update entity with partial restore data resets state.""" + mock_restore_cache( + hass, + [ + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "11.2.4", + ATTR_SKIPPED_VERSION: "11.2.4", + }, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_UNKNOWN + + +async def test_update_entity_full_restore_data_skipped_version( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test update entity with full restore data (skipped version) restores state.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "11.2.4", + ATTR_SKIPPED_VERSION: "11.2.4", + }, + ), + {"latest_version_firmware": LATEST_VERSION_FIRMWARE}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_SKIPPED_VERSION] == "11.2.4" + assert state.attributes[ATTR_LATEST_VERSION] == "11.2.4" + + +async def test_update_entity_full_restore_data_update_available( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test update entity with full restore data (update available) restores state.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "11.2.4", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"latest_version_firmware": LATEST_VERSION_FIRMWARE}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] == "11.2.4" + + client.async_send_command.return_value = {"success": True} + + # Test successful install call without a version + install_task = hass.async_create_task( + hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + ) + + # Sleep so that task starts + await asyncio.sleep(0.1) + + state = hass.states.get(UPDATE_ENTITY) + assert state + attrs = state.attributes + assert attrs[ATTR_IN_PROGRESS] is True + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args_list[0][0][0] == { + "command": "controller.firmware_update_ota", + "nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id, + "updates": [{"target": 0, "url": "https://example2.com", "integrity": "sha2"}], + } + + install_task.cancel() + + +async def test_update_entity_full_restore_data_no_update_available( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test entity with full restore data (no update available) restores state.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: "10.7", + ATTR_SKIPPED_VERSION: None, + }, + ), + {"latest_version_firmware": None}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] == "10.7" From 5e5ace9c4e3ad996e4db45e99b14c4df8402b416 Mon Sep 17 00:00:00 2001 From: Arturo Date: Mon, 20 Mar 2023 18:29:33 -0600 Subject: [PATCH 0631/1058] Add door lock device type to matter integration (#89277) * Adds base code for matter lock * Adds basic matter door lock support * Adds matter lock fixture * Adds tests for matter lock * Addresses feedback * Added logic to handle inter states of matter lock * Addesses feedback --- homeassistant/components/matter/discovery.py | 2 + homeassistant/components/matter/light.py | 7 +- homeassistant/components/matter/lock.py | 141 +++++ homeassistant/components/matter/switch.py | 9 +- .../matter/fixtures/nodes/door-lock.json | 509 ++++++++++++++++++ tests/components/matter/test_door_lock.py | 106 ++++ 6 files changed, 769 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/matter/lock.py create mode 100644 tests/components/matter/fixtures/nodes/door-lock.json create mode 100644 tests/components/matter/test_door_lock.py diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 3fb8481dc94d..36f415dacc01 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -11,6 +11,7 @@ from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS +from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS @@ -18,6 +19,7 @@ from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, + Platform.LOCK: LOCK_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, } diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 080cc472f2db..10a52eb88055 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -372,7 +372,10 @@ DISCOVERY_SCHEMAS = [ clusters.ColorControl.Attributes.CurrentY, clusters.ColorControl.Attributes.ColorTemperatureMireds, ), - # restrict device type to prevent discovery in switch platform - not_device_type=(device_types.OnOffPlugInUnit,), + # restrict device type to prevent discovery by the wrong platform + not_device_type=( + device_types.OnOffPlugInUnit, + device_types.DoorLock, + ), ), ] diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py new file mode 100644 index 000000000000..f90d8eb485d0 --- /dev/null +++ b/homeassistant/components/matter/lock.py @@ -0,0 +1,141 @@ +"""Matter lock.""" +from __future__ import annotations + +from enum import IntFlag +from typing import Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.lock import LockEntity, LockEntityDescription +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 .const import LOGGER +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter lock from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.LOCK, async_add_entities) + + +class MatterLock(MatterEntity, LockEntity): + """Representation of a Matter lock.""" + + features: int | None = None + + @property + def supports_door_position_sensor(self) -> bool: + """Return True if the lock supports door position sensor.""" + if self.features is None: + return False + + return bool(self.features & DoorLockFeature.kDoorPositionSensor) + + async def send_device_command( + self, + command: clusters.ClusterCommand, + timed_request_timeout_ms: int = 1000, + ) -> None: + """Send a command to the device.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + timed_request_timeout_ms=timed_request_timeout_ms, + ) + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock with pin if needed.""" + await self.send_device_command(command=clusters.DoorLock.Commands.LockDoor()) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the lock with pin if needed.""" + await self.send_device_command(command=clusters.DoorLock.Commands.UnlockDoor()) + + @callback + def _update_from_device(self) -> None: + """Update the entity from the device.""" + + if self.features is None: + self.features = int( + self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap) + ) + + lock_state = self.get_matter_attribute_value( + clusters.DoorLock.Attributes.LockState + ) + + LOGGER.debug("Lock state: %s for %s", lock_state, self.entity_id) + + if lock_state is clusters.DoorLock.Enums.DlLockState.kLocked: + self._attr_is_locked = True + self._attr_is_locking = False + self._attr_is_unlocking = False + elif lock_state is clusters.DoorLock.Enums.DlLockState.kUnlocked: + self._attr_is_locked = False + self._attr_is_locking = False + self._attr_is_unlocking = False + elif lock_state is clusters.DoorLock.Enums.DlLockState.kNotFullyLocked: + if self.is_locked is True: + self._attr_is_unlocking = True + elif self.is_locked is False: + self._attr_is_locking = True + else: + # According to the matter docs a null state can happen during device startup. + self._attr_is_locked = None + self._attr_is_locking = None + self._attr_is_unlocking = None + + if self.supports_door_position_sensor: + door_state = self.get_matter_attribute_value( + clusters.DoorLock.Attributes.DoorState + ) + + assert door_state is not None + + LOGGER.debug("Door state: %s for %s", door_state, self.entity_id) + + self._attr_is_jammed = ( + door_state is clusters.DoorLock.Enums.DlDoorState.kDoorJammed + ) + + +class DoorLockFeature(IntFlag): + """Temp enum that represents the features of a door lock. + + Should be replaced by the library provided one once that is released. + """ + + kPinCredential = 0x1 + kRfidCredential = 0x2 + kFingerCredentials = 0x4 + kLogging = 0x8 + kWeekDayAccessSchedules = 0x10 + kDoorPositionSensor = 0x20 + kFaceCredentials = 0x40 + kCredentialsOverTheAirAccess = 0x80 + kUser = 0x100 + kNotification = 0x200 + kYearDayAccessSchedules = 0x400 + kHolidaySchedules = 0x800 + + +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.LOCK, + entity_description=LockEntityDescription(key="MatterLock"), + entity_class=MatterLock, + required_attributes=(clusters.DoorLock.Attributes.LockState,), + optional_attributes=(clusters.DoorLock.Attributes.DoorState,), + ), +] diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index e5c986104397..809d0ad73861 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -67,8 +67,11 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSwitch, required_attributes=(clusters.OnOff.Attributes.OnOff,), - # restrict device type to prevent discovery by light - # platform which also uses OnOff cluster - not_device_type=(device_types.OnOffLight, device_types.DimmableLight), + # restrict device type to prevent discovery by the wrong platform + not_device_type=( + device_types.OnOffLight, + device_types.DimmableLight, + device_types.DoorLock, + ), ), ] diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door-lock.json new file mode 100644 index 000000000000..f7a9749325fe --- /dev/null +++ b/tests/components/matter/fixtures/nodes/door-lock.json @@ -0,0 +1,509 @@ +{ + "node_id": 1, + "date_commissioned": "2023-03-07T09:06:06.059454", + "last_interview": "2023-03-07T09:06:06.059456", + "interview_version": 2, + "available": true, + "attributes": { + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 43, 44, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 60, 62, + 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "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, 65530, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Door Lock", + "0/40/4": 32769, + "0/40/5": "Mock Door Lock", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-door-lock", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 65535 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/46/0": [0, 1], + "0/46/65532": 0, + "0/46/65533": 1, + "0/46/65528": [], + "0/46/65529": [], + "0/46/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "0/47/0": 1, + "0/47/1": 0, + "0/47/2": "USB", + "0/47/6": 0, + "0/47/65532": 1, + "0/47/65533": 1, + "0/47/65528": [], + "0/47/65529": [], + "0/47/65531": [0, 1, 2, 6, 65528, 65529, 65530, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "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, 65530, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "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, 2, 3, 4, 5, 6, 7, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65530, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "eth0", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "/mQDt/2Q", + "IPv4Addresses": ["CjwBaQ=="], + "IPv6Addresses": [ + "/VqgxiAxQib8ZAP//rf9kA==", + "IAEEcLs7AAb8ZAP//rf9kA==", + "/oAAAAAAAAD8ZAP//rf9kA==" + ], + "type": 2 + }, + { + "name": "lo", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "AAAAAAAA", + "IPv4Addresses": ["fwAAAQ=="], + "IPv6Addresses": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "type": 0 + } + ], + "0/51/1": 1, + "0/51/2": 25, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "id": 26957, + "name": "26957", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26956, + "name": "26956", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26955, + "name": "26955", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26953, + "name": "26953", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + }, + { + "id": 26952, + "name": "26952", + "stackFreeCurrent": null, + "stackFreeMinimum": null, + "stackSize": null + } + ], + "0/52/1": 351120, + "0/52/2": 529520, + "0/52/3": 529520, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/53/0": null, + "0/53/1": null, + "0/53/2": null, + "0/53/3": null, + "0/53/4": null, + "0/53/5": null, + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": null, + "0/53/10": null, + "0/53/11": null, + "0/53/12": null, + "0/53/13": null, + "0/53/14": 0, + "0/53/15": 0, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 0, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 0, + "0/53/23": 0, + "0/53/24": 0, + "0/53/25": 0, + "0/53/26": 0, + "0/53/27": 0, + "0/53/28": 0, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 0, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 0, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 0, + "0/53/40": 0, + "0/53/41": 0, + "0/53/42": 0, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 0, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": null, + "0/53/57": null, + "0/53/58": null, + "0/53/59": null, + "0/53/60": null, + "0/53/61": null, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/54/0": null, + "0/54/1": null, + "0/54/2": 3, + "0/54/3": null, + "0/54/4": null, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65530, 65531, + 65532, 65533 + ], + "0/55/0": null, + "0/55/1": false, + "0/55/2": 823, + "0/55/3": 969, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 25, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVASQRARgkBwEkCAEwCUEE55h6CbNLPZH/uM3/rDdA+jeuuD2QSPN8gBeEB0bmGJqWz/gCT4/ySB77rK3XiwVWVAmJhJ/eMcTIA0XXWMqKPDcKNQEoARgkAgE2AwQCBAEYMAQUqnKiC76YFhcTHt4AQ/kAbtrZ2MowBRSL6EWyWm8+uC0Puc2/BncMqYbpmhgwC0AA05Z+y1mcyHUeOFJ5kyDJJMN/oNCwN5h8UpYN/868iuQArr180/fbaN1+db9lab4D2lf0HK7wgHIR3HsOa2w9GA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE5R1DrUQE/L8tx95WR1g1dZJf4d+6LEB7JAYZN/nw9ZBUg5VOHDrB1xIw5KguYJzt10K+0KqQBBEbuwW+wLLobTcKNQEpARgkAmAwBBSL6EWyWm8+uC0Puc2/BncMqYbpmjAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQHXLE0TgIDW6XOnvtsOJCyKoENts8d4TQWBgTKviv1LF/+MS9eFYi+kO+1Idq5mVgwN+lH7eyecShQR0iqq6WLUY", + "fabricIndex": 1 + } + ], + "0/62/1": [ + { + "rootPublicKey": "BJ/jL2MdDrdq9TahKSa5c/dBc166NRCU0W9l7hK2kcuVtN915DLqiS+RAJ2iPEvWK5FawZHF/QdKLZmTkZHudxY=", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEn+MvYx0Ot2r1NqEpJrlz90FzXro1EJTRb2XuEraRy5W033XkMuqJL5EAnaI8S9YrkVrBkcX9B0otmZORke53FjcKNQEpARgkAmAwBBTNCOnzxc3zr9iFm19YbsW93ltH6jAFFM0I6fPFzfOv2IWbX1huxb3eW0fqGDALQILjpR3BTSHHl6DQtvwzWkjmA+i5jjXdc3qjemFGFjFVAnV6dPLQo7tctC8Y0uL4ZNERga2/NZAt1gRD72S0YR4Y" + ], + "0/62/5": 1, + "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, 65530, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65530, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65530, 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, 65530, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": 0, + "1/6/65532": 0, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "type": 10, + "revision": 1 + } + ], + "1/29/1": [3, 6, 29, 47, 257], + "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, 65530, 65531, 65532, 65533], + "1/47/0": 1, + "1/47/1": 1, + "1/47/2": "Battery", + "1/47/14": 0, + "1/47/15": false, + "1/47/16": 0, + "1/47/19": "", + "1/47/65532": 10, + "1/47/65533": 1, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [ + 0, 1, 2, 14, 15, 16, 19, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "1/257/0": 1, + "1/257/1": 0, + "1/257/2": true, + "1/257/3": 1, + "1/257/17": 10, + "1/257/18": 10, + "1/257/19": 10, + "1/257/20": 10, + "1/257/21": 10, + "1/257/22": 10, + "1/257/23": 8, + "1/257/24": 6, + "1/257/25": 20, + "1/257/26": 10, + "1/257/27": 1, + "1/257/28": 5, + "1/257/33": "en", + "1/257/35": 60, + "1/257/36": 0, + "1/257/37": 0, + "1/257/38": 65526, + "1/257/41": false, + "1/257/43": false, + "1/257/48": 3, + "1/257/49": 10, + "1/257/51": false, + "1/257/65532": 3507, + "1/257/65533": 6, + "1/257/65528": [12, 15, 18, 28, 35, 37], + "1/257/65529": [ + 0, 1, 3, 11, 12, 13, 14, 15, 16, 17, 18, 19, 26, 27, 29, 34, 36, 38 + ], + "1/257/65531": [ + 0, 1, 2, 3, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 33, 35, 36, + 37, 38, 41, 43, 48, 49, 51, 65528, 65529, 65530, 65531, 65532, 65533 + ] + } +} diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py new file mode 100644 index 000000000000..072658044d83 --- /dev/null +++ b/tests/components/matter/test_door_lock.py @@ -0,0 +1,106 @@ +"""Test Matter locks.""" +from unittest.mock import MagicMock, call + +from chip.clusters import Objects as clusters +from matter_server.client.models.node import MatterNode +import pytest + +from homeassistant.components.lock import ( + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@pytest.fixture(name="door_lock") +async def door_lock_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a door lock node.""" + return await setup_integration_with_node_fixture(hass, "door-lock", matter_client) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_lock( + hass: HomeAssistant, + matter_client: MagicMock, + door_lock: MatterNode, +) -> None: + """Test door lock.""" + await hass.services.async_call( + "lock", + "unlock", + { + "entity_id": "lock.mock_door_lock", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.UnlockDoor(), + timed_request_timeout_ms=1000, + ) + matter_client.send_device_command.reset_mock() + + await hass.services.async_call( + "lock", + "lock", + { + "entity_id": "lock.mock_door_lock", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.LockDoor(), + timed_request_timeout_ms=1000, + ) + matter_client.send_device_command.reset_mock() + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_LOCKED + + set_node_attribute(door_lock, 1, 257, 0, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_UNLOCKING + + set_node_attribute(door_lock, 1, 257, 0, 2) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_UNLOCKED + + set_node_attribute(door_lock, 1, 257, 0, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_LOCKING + + set_node_attribute(door_lock, 1, 257, 0, None) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("lock.mock_door_lock") + assert state + assert state.state == STATE_UNKNOWN From 7158dbc142ade94e8083bdc6de18db84fc57ab25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 17:49:30 -1000 Subject: [PATCH 0632/1058] Bump yalexs-ble to 2.1.1 (#90015) * Bump yalexs-ble to 2.1.1 There was another task that could be prematurely GCed changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.0...v2.1.1 * fixes --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 6 ++---- requirements_test_all.txt | 6 ++---- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index eba6e2c1b391..213f0237e124 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.2.7", "yalexs_ble==2.1.0"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.1"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index e793fe272865..6bb58752a00f 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.1.0"] + "requirements": ["yalexs-ble==2.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index be03dac8c0eb..7e74fa1e8ace 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2669,15 +2669,13 @@ xs1-api-client==3.0.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.9 +# homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.0 +yalexs-ble==2.1.1 # homeassistant.components.august yalexs==1.2.7 -# homeassistant.components.august -yalexs_ble==2.1.0 - # homeassistant.components.yeelight yeelight==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38d107577199..443f1b79e963 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1903,15 +1903,13 @@ xmltodict==0.13.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.9 +# homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.0 +yalexs-ble==2.1.1 # homeassistant.components.august yalexs==1.2.7 -# homeassistant.components.august -yalexs_ble==2.1.0 - # homeassistant.components.yeelight yeelight==0.7.10 From 030361870521659b7b2ce8446fff33fb9d3a0717 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 17:49:59 -1000 Subject: [PATCH 0633/1058] Handle cancelation of wait_for_ble_connections_free in esphome bluetooth (#90014) Handle cancelation in wait_for_ble_connections_free If `wait_for_ble_connections_free` was canceled due to timeout or the esp disconnecting from Home Assistant the future would get canceled. When we reconnect and get the next callback we need to handle it being done. fixes ``` 2023-03-21 02:34:36.876 ERROR (MainThread) [homeassistant] Error doing job: Fatal error: protocol.data_received() call failed. Traceback (most recent call last): File "/usr/local/lib/python3.10/asyncio/selector_events.py", line 868, in _read_ready__data_received self._protocol.data_received(data) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/_frame_helper.py", line 195, in data_received self._callback_packet(msg_type_int, bytes(packet_data)) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/_frame_helper.py", line 110, in _callback_packet self._on_pkt(Packet(type_, data)) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/connection.py", line 688, in _process_packet handler(msg) File "/usr/local/lib/python3.10/site-packages/aioesphomeapi/client.py", line 482, in on_msg on_bluetooth_connections_free_update(resp.free, resp.limit) File "/usr/src/homeassistant/homeassistant/components/esphome/entry_data.py", line 136, in async_update_ble_connection_limits fut.set_result(free) asyncio.exceptions.InvalidStateError: invalid state ``` --- homeassistant/components/esphome/entry_data.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 4d035427085e..d7f25f319ac0 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -131,10 +131,15 @@ class RuntimeEntryData: ) self.ble_connections_free = free self.ble_connections_limit = limit - if free: - for fut in self._ble_connection_free_futures: + if not free: + return + for fut in self._ble_connection_free_futures: + # If wait_for_ble_connections_free gets cancelled, it will + # leave a future in the list. We need to check if it's done + # before setting the result. + if not fut.done(): fut.set_result(free) - self._ble_connection_free_futures.clear() + self._ble_connection_free_futures.clear() async def wait_for_ble_connections_free(self) -> int: """Wait until there are free BLE connections.""" From 91dbda1ce7eda8d6e685770b9b80e26e4338c576 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Mar 2023 08:20:37 +0100 Subject: [PATCH 0634/1058] Add mqtt common tests for availability (part2) (#89805) * update test_availability_when_connection_lost * Adjust test_availability_without_topic * Update test_default_availability_payload + helper * Update test_default_availability_list_payload * Use helper for async_setup_component * Update test_default_availability_list_* * Update test_custom_availability_payload --- .../mqtt/test_alarm_control_panel.py | 21 ++- tests/components/mqtt/test_binary_sensor.py | 30 ++-- tests/components/mqtt/test_button.py | 19 +-- tests/components/mqtt/test_camera.py | 18 ++- tests/components/mqtt/test_climate.py | 18 ++- tests/components/mqtt/test_common.py | 151 ++++++++---------- tests/components/mqtt/test_cover.py | 18 ++- tests/components/mqtt/test_fan.py | 18 ++- tests/components/mqtt/test_humidifier.py | 18 ++- tests/components/mqtt/test_legacy_vacuum.py | 18 ++- tests/components/mqtt/test_light.py | 18 ++- tests/components/mqtt/test_light_json.py | 18 ++- tests/components/mqtt/test_light_template.py | 18 ++- tests/components/mqtt/test_lock.py | 18 ++- tests/components/mqtt/test_number.py | 18 ++- tests/components/mqtt/test_scene.py | 19 +-- tests/components/mqtt/test_select.py | 18 ++- tests/components/mqtt/test_sensor.py | 30 ++-- tests/components/mqtt/test_siren.py | 19 +-- tests/components/mqtt/test_state_vacuum.py | 18 ++- tests/components/mqtt/test_switch.py | 19 +-- tests/components/mqtt/test_text.py | 19 +-- tests/components/mqtt/test_update.py | 18 ++- 23 files changed, 290 insertions(+), 289 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 8056e98a3c5d..0e760c69a8e8 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -593,49 +593,48 @@ async def test_attributes_code_text( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_CODE]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, - mqtt_mock_entry_with_yaml_config, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG_CODE, + hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_CODE]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 73176de9edd7..344a33ad1acf 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -547,51 +547,41 @@ async def test_invalid_device_class( assert "Invalid config for [mqtt]: expected BinarySensorDeviceClass" in caplog.text +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index c4aa5a8606af..4902deaa4929 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -136,26 +136,28 @@ async def test_command_template( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -167,10 +169,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, button.DOMAIN, config, True, @@ -180,7 +181,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -195,7 +196,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, button.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index dc96d3e9cdb2..5ba74b0999f2 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -157,39 +157,41 @@ async def test_camera_b64_encoded_with_availability( assert body == "grass" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index f9f13034eaf1..a9c1ca6598ae 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1042,39 +1042,41 @@ async def test_set_aux( assert state.attributes.get("aux_heat") == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 0f54e8394986..88d993ef5d61 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -27,13 +27,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.generated.mqtt import MQTT -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient +from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient DEFAULT_CONFIG_DEVICE_INFO_ID = { "identifiers": ["helloworld"], @@ -82,16 +86,46 @@ def help_test_validate_platform_config( return False -async def help_test_availability_when_connection_lost( +async def help_setup_component( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry: MqttMockHAClientGenerator | None, domain: str, config: ConfigType, + use_discovery: bool = False, +) -> MqttMockHAClient | None: + """Help to set up the MQTT component.""" + # `async_setup_component` will call `async_setup` and + # after that it will also start the entry `async_start_entry` + # when `async_setup` removed mqtt_mock_entry_with_no_config should be awaited. + + if use_discovery: + comp_config = cv.ensure_list(config[mqtt.DOMAIN][domain]) + item = 0 + assert mqtt_mock_entry is not None + mqtt_mock = await mqtt_mock_entry() + for comp in comp_config: + item += 1 + topic = f"homeassistant/{domain}/item_{item}/config" + async_fire_mqtt_message(hass, topic, json.dumps(comp)) + else: + await async_setup_component( + hass, + mqtt.DOMAIN, + config, + ) + mqtt_mock = None + await hass.async_block_till_done() + return mqtt_mock + + +async def help_test_availability_when_connection_lost( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + domain: str, ) -> None: """Test availability after MQTT disconnection.""" - assert await async_setup_component(hass, mqtt.DOMAIN, config) + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state and state.state != STATE_UNAVAILABLE @@ -106,15 +140,14 @@ async def help_test_availability_when_connection_lost( async def help_test_availability_without_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: """Test availability without defined availability topic.""" assert "availability_topic" not in config[mqtt.DOMAIN][domain] - assert await async_setup_component(hass, mqtt.DOMAIN, config) + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state and state.state != STATE_UNAVAILABLE @@ -122,7 +155,7 @@ async def help_test_availability_without_topic( async def help_test_default_availability_payload( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_with_no_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -136,13 +169,8 @@ async def help_test_default_availability_payload( # Add availability settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + + await help_setup_component(hass, mqtt_mock_entry_with_no_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -173,7 +201,7 @@ async def help_test_default_availability_payload( async def help_test_default_availability_list_payload( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_with_no_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -190,13 +218,7 @@ async def help_test_default_availability_list_payload( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_with_no_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -239,7 +261,7 @@ async def help_test_default_availability_list_payload( async def help_test_default_availability_list_payload_all( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -257,13 +279,7 @@ async def help_test_default_availability_list_payload_all( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -307,7 +323,7 @@ async def help_test_default_availability_list_payload_all( async def help_test_default_availability_list_payload_any( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -325,13 +341,7 @@ async def help_test_default_availability_list_payload_any( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -384,20 +394,17 @@ async def help_test_default_availability_list_single( {"topic": "availability-topic1"}, ] config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) + help_test_validate_platform_config(hass, domain, config) + assert ( - "Invalid config for [mqtt]: two or more values in the same group of exclusion 'availability'" + f"Invalid config for [{domain}]: two or more values in the same group of exclusion 'availability'" in caplog.text ) async def help_test_custom_availability_payload( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -413,13 +420,7 @@ async def help_test_custom_availability_payload( config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" config[mqtt.DOMAIN][domain]["payload_available"] = "good" config[mqtt.DOMAIN][domain]["payload_not_available"] = "nogood" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -528,7 +529,7 @@ async def help_test_discovery_update_availability( async def help_test_setting_attribute_via_mqtt_json_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -539,13 +540,7 @@ async def help_test_setting_attribute_via_mqtt_json_message( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') state = hass.states.get(f"{domain}.test") @@ -588,7 +583,7 @@ async def help_test_setting_blocked_attribute_via_mqtt_json_message( async def help_test_setting_attribute_with_template( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, ) -> None: @@ -602,13 +597,7 @@ async def help_test_setting_attribute_with_template( config[mqtt.DOMAIN][domain][ "json_attributes_template" ] = "{{ value_json['Timer1'] | tojson }}" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) async_fire_mqtt_message( hass, "attr-topic", json.dumps({"Timer1": {"Arm": 0, "Time": "22:18"}}) @@ -622,7 +611,7 @@ async def help_test_setting_attribute_with_template( async def help_test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -634,13 +623,7 @@ async def help_test_update_with_json_attrs_not_dict( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') state = hass.states.get(f"{domain}.test") @@ -651,7 +634,7 @@ async def help_test_update_with_json_attrs_not_dict( async def help_test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -663,13 +646,7 @@ async def help_test_update_with_json_attrs_bad_json( # Add JSON attributes settings to config config = copy.deepcopy(config) config[mqtt.DOMAIN][domain]["json_attributes_topic"] = "attr-topic" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 27eac0842c28..9618ff7b047b 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -2508,39 +2508,41 @@ async def test_find_in_range_altered_inverted(hass: HomeAssistant) -> None: assert mqtt_cover.find_in_range_from_percent(60, "cover") == 120 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 1a0bc4faf52a..0b6f32a2fdfe 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1657,31 +1657,33 @@ async def test_supported_features( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG, True, @@ -1691,12 +1693,12 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG, True, diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 653f5ea7810d..f74e883c4a00 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1010,31 +1010,33 @@ async def test_supported_features( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG, True, @@ -1044,12 +1046,12 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG, True, diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 6d45cd4898ab..17ae575dca3e 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -631,39 +631,41 @@ async def test_missing_fan_speed_template(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index a947bfc79735..515c41b8bad4 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -2133,39 +2133,41 @@ async def test_effect( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index de8ba889e274..f9c1a637932a 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -1882,39 +1882,41 @@ async def test_invalid_values( assert state.attributes.get("color_temp") == 100 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index a5d0f009ca9e..944f6ad016ae 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -850,39 +850,41 @@ async def test_invalid_values( assert state.attributes.get("effect") == "rainbow" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 1d48640011c9..88b86ef153a2 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -652,39 +652,41 @@ async def test_sending_mqtt_commands_pessimistic( assert state.state is STATE_LOCKED +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index da7e278e0307..b4c032468ec1 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -445,39 +445,41 @@ async def test_run_number_service_with_command_template( assert state.state == "32" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index b6899062d22c..3662cd6a1cad 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -79,26 +79,28 @@ async def test_sending_mqtt_commands( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, scene.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, scene.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, scene.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, scene.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -110,10 +112,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, scene.DOMAIN, config, True, @@ -123,7 +124,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -138,7 +139,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, scene.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 31eb7daff871..f7ebd8fb7ff8 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -322,39 +322,41 @@ async def test_run_select_service_with_command_template( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index c1bc07f77aaa..a76f7ec28978 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -661,57 +661,59 @@ async def test_force_update_enabled( assert len(events) == 2 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_list_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_list_payload_all( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload_all( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_list_payload_any( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload_any( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -728,11 +730,11 @@ async def test_default_availability_list_single( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 329a9150f718..252a9d13f61c 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -457,26 +457,28 @@ async def test_filtering_not_supported_attributes_via_state( assert state3.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.88 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -490,10 +492,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, siren.DOMAIN, config, True, @@ -503,7 +504,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -520,7 +521,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, siren.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 55a2a773f6cf..e8622ceb9b26 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -368,39 +368,41 @@ async def test_status_invalid_json( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 1eb229461878..a11155f39228 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -219,26 +219,28 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -252,10 +254,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, switch.DOMAIN, config, True, @@ -265,7 +266,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -282,7 +283,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, switch.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 83b2a0d55ebb..ed2b2a2e2bf4 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -313,26 +313,28 @@ async def test_set_text_validation( assert state.state == "no" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" config = { @@ -344,10 +346,9 @@ async def test_default_availability_payload( } } } - await help_test_default_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, text.DOMAIN, config, True, @@ -357,7 +358,7 @@ async def test_default_availability_payload( async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" config = { @@ -372,7 +373,7 @@ async def test_custom_availability_payload( await help_test_custom_availability_payload( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, text.DOMAIN, config, True, diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 4821aeca8eb9..c324d7d7bf06 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -428,39 +428,41 @@ async def test_run_install_service( mqtt_mock.async_publish.assert_called_once_with(command_topic, "install", 0, False) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_without_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) async def test_default_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) async def test_custom_availability_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) From a8e95684fa3c447e6dc5eb5936448c61e84d8f0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Mar 2023 21:51:39 -1000 Subject: [PATCH 0635/1058] Fix websocket back pressure bottleneck (#89905) --- .../components/websocket_api/http.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index d92e52dbf843..de0b23e49572 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -268,6 +268,43 @@ class WebSocketHandler: ) async_dispatcher_send(self.hass, SIGNAL_WEBSOCKET_CONNECTED) + # + # + # Our websocket implementation is backed by an asyncio.Queue + # + # As back-pressure builds, the queue will back up and use more memory + # until we disconnect the client when the queue size reaches + # MAX_PENDING_MSG. When we are generating a high volume of websocket messages, + # we hit a bottleneck in aiohttp where it will wait for + # the buffer to drain before sending the next message and messages + # start backing up in the queue. + # + # https://github.com/aio-libs/aiohttp/issues/1367 added drains + # to the websocket writer to handle malicious clients and network issues. + # The drain causes multiple problems for us since the buffer cannot be + # drained fast enough when we deliver a high volume or large messages: + # + # - We end up disconnecting the client. The client will then reconnect, + # and the cycle repeats itself, which results in a significant amount of + # CPU usage. + # + # - Messages latency increases because messages cannot be moved into + # the TCP buffer because it is blocked waiting for the drain to happen because + # of the low default limit of 16KiB. By increasing the limit, we instead + # rely on the underlying TCP buffer and stack to deliver the messages which + # can typically happen much faster. + # + # After the auth phase is completed, and we are not concerned about + # the user being a malicious client, we set the limit to force a drain + # to 1MiB. 1MiB is the maximum expected size of the serialized entity + # registry, which is the largest message we usually send. + # + # https://github.com/aio-libs/aiohttp/commit/b3c80ee3f7d5d8f0b8bc27afe52e4d46621eaf99 + # 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 + # Command phase while not wsock.closed: msg = await wsock.receive() From 0c0c86bf7b596988f51b0327eba7533690a4efc3 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 21 Mar 2023 03:56:44 -0400 Subject: [PATCH 0636/1058] Add support for new Insteon i3 devcies (#89892) --- homeassistant/components/insteon/ipdb.py | 28 +++++++++++++------ .../components/insteon/manifest.json | 4 +-- requirements_all.txt | 4 +-- requirements_test_all.txt | 4 +-- tests/components/insteon/mock_devices.py | 4 +-- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index 46302d3e6ad6..fea1262bffdf 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -4,14 +4,17 @@ from pyinsteon.device_types.ipdb import ( ClimateControl_Thermostat, ClimateControl_WirelessThermostat, DimmableLightingControl, + DimmableLightingControl_Dial, DimmableLightingControl_DinRail, DimmableLightingControl_FanLinc, - DimmableLightingControl_InLineLinc, + DimmableLightingControl_InLineLinc01, + DimmableLightingControl_InLineLinc02, DimmableLightingControl_KeypadLinc_6, DimmableLightingControl_KeypadLinc_8, DimmableLightingControl_LampLinc, DimmableLightingControl_OutletLinc, - DimmableLightingControl_SwitchLinc, + DimmableLightingControl_SwitchLinc01, + DimmableLightingControl_SwitchLinc02, DimmableLightingControl_ToggleLinc, EnergyManagement_LoadController, GeneralController_ControlLinc, @@ -28,12 +31,15 @@ from pyinsteon.device_types.ipdb import ( SwitchedLightingControl, SwitchedLightingControl_ApplianceLinc, SwitchedLightingControl_DinRail, - SwitchedLightingControl_InLineLinc, + SwitchedLightingControl_I3Outlet, + SwitchedLightingControl_InLineLinc01, + SwitchedLightingControl_InLineLinc02, SwitchedLightingControl_KeypadLinc_6, SwitchedLightingControl_KeypadLinc_8, SwitchedLightingControl_OnOffOutlet, SwitchedLightingControl_OutletLinc, - SwitchedLightingControl_SwitchLinc, + SwitchedLightingControl_SwitchLinc01, + SwitchedLightingControl_SwitchLinc02, SwitchedLightingControl_ToggleLinc, WindowCovering, X10Dimmable, @@ -54,9 +60,11 @@ from .const import ON_OFF_EVENTS DEVICE_PLATFORM = { AccessControl_Morningstar: {LOCK: [1]}, DimmableLightingControl: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_Dial: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_DinRail: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_FanLinc: {LIGHT: [1], FAN: [2], ON_OFF_EVENTS: [1, 2]}, - DimmableLightingControl_InLineLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_InLineLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_InLineLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_KeypadLinc_6: { LIGHT: [1], SWITCH: [3, 4, 5, 6], @@ -69,7 +77,8 @@ DEVICE_PLATFORM = { }, DimmableLightingControl_LampLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_OutletLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_SwitchLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_SwitchLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_SwitchLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]}, DimmableLightingControl_ToggleLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, EnergyManagement_LoadController: {SWITCH: [1], BINARY_SENSOR: [2]}, GeneralController_ControlLinc: {ON_OFF_EVENTS: [1]}, @@ -86,7 +95,9 @@ DEVICE_PLATFORM = { SwitchedLightingControl: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_ApplianceLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_DinRail: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_InLineLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_I3Outlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, + SwitchedLightingControl_InLineLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_InLineLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_KeypadLinc_6: { SWITCH: [1, 3, 4, 5, 6], ON_OFF_EVENTS: [1, 3, 4, 5, 6], @@ -97,7 +108,8 @@ DEVICE_PLATFORM = { }, SwitchedLightingControl_OnOffOutlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, SwitchedLightingControl_OutletLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_SwitchLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_SwitchLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + SwitchedLightingControl_SwitchLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, SwitchedLightingControl_ToggleLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, ClimateControl_Thermostat: {CLIMATE: [1]}, ClimateControl_WirelessThermostat: {CLIMATE: [1]}, diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 743e7e4fa19d..af9396399af9 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,8 +17,8 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.3.4", - "insteon-frontend-home-assistant==0.3.3" + "pyinsteon==1.4.0", + "insteon-frontend-home-assistant==0.3.4" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 7e74fa1e8ace..d7d30cc98711 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -979,7 +979,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.3 +insteon-frontend-home-assistant==0.3.4 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1687,7 +1687,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.4 +pyinsteon==1.4.0 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 443f1b79e963..19e29a7c2fd9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -741,7 +741,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.3 +insteon-frontend-home-assistant==0.3.4 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1215,7 +1215,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.3.4 +pyinsteon==1.4.0 # homeassistant.components.ipma pyipma==3.0.6 diff --git a/tests/components/insteon/mock_devices.py b/tests/components/insteon/mock_devices.py index e1f36ab64111..dd0ab0b56a0f 100644 --- a/tests/components/insteon/mock_devices.py +++ b/tests/components/insteon/mock_devices.py @@ -11,14 +11,14 @@ from pyinsteon.device_types.ipdb import ( GeneralController_RemoteLinc, Hub, SensorsActuators_IOLink, - SwitchedLightingControl_SwitchLinc, + SwitchedLightingControl_SwitchLinc02, ) from pyinsteon.managers.saved_devices_manager import dict_to_aldb_record from pyinsteon.topics import DEVICE_LIST_CHANGED from pyinsteon.utils import subscribe_topic -class MockSwitchLinc(SwitchedLightingControl_SwitchLinc): +class MockSwitchLinc(SwitchedLightingControl_SwitchLinc02): """Mock SwitchLinc device.""" @property From dd1700954b92309cb33d57f8462da5579adf45da Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 09:00:17 +0100 Subject: [PATCH 0637/1058] Deprecate YAML in SamsungTV (#89743) Co-authored-by: Franck Nijhof --- .../components/samsungtv/__init__.py | 21 ++++++++++- .../components/samsungtv/media_player.py | 14 ++++--- .../components/samsungtv/strings.json | 6 +++ .../samsungtv/snapshots/test_init.ambr | 10 +++++ .../samsungtv/test_device_trigger.py | 14 ++----- tests/components/samsungtv/test_init.py | 10 ++++- tests/components/samsungtv/test_trigger.py | 37 ++++++++----------- 7 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 tests/components/samsungtv/snapshots/test_init.ambr diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 993100262e79..0d90157f76ba 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -26,8 +26,12 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType @@ -92,6 +96,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "on_action_url": "https://www.home-assistant.io/integrations/samsungtv/#turn-on-action" + }, + learn_more_url="https://www.home-assistant.io/integrations/samsungtv/#turn-on-action", + ) for entry_config in config[DOMAIN]: ip_address = await hass.async_add_executor_job( socket.gethostbyname, entry_config[CONF_HOST] diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 3fac14a82c81..4e66a9c1d246 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -33,10 +33,12 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_component +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_component, +) 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 from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.script import Script @@ -144,7 +146,7 @@ class SamsungTVDevice(MediaPlayerEntity): self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} if self._mac: self._attr_device_info["connections"] = { - (CONNECTION_NETWORK_MAC, self._mac) + (dr.CONNECTION_NETWORK_MAC, self._mac) } # Mark the end of a shutdown command (need to wait 15 seconds before @@ -475,8 +477,8 @@ class SamsungTVDevice(MediaPlayerEntity): """Turn the media player on.""" if self._turn_on: await self._turn_on.async_run(self.hass, self._context) - # on_script is deprecated - replaced by turn_on trigger - if self._on_script: + elif self._on_script: + # YAML on_script is deprecated - replaced by turn_on trigger await self._on_script.async_run(context=self._context) elif self._mac: await self.hass.async_add_executor_job(self._wake_on_lan) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index f1f237fa4fb0..cfa04244e829 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -44,5 +44,11 @@ "trigger_type": { "samsungtv.turn_on": "Device is requested to turn on" } + }, + "issues": { + "deprecated_yaml": { + "title": "The SamsungTV YAML configuration is being removed", + "description": "Configuring SamsungTV using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the SamsungTV YAML configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.\n\nPlease note that previously configured `turn_on_action` needs to be manually converted to use the `turn_on` trigger ([documentation]({on_action_url}))." + } } } diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr new file mode 100644 index 000000000000..877bfe04205c --- /dev/null +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -0,0 +1,10 @@ +# serializer version: 1 +# name: test_setup + IssueRegistryItemSnapshot({ + 'created': , + 'dismissed_version': None, + 'domain': 'samsungtv', + 'is_persistent': False, + 'issue_id': 'deprecated_yaml', + }) +# --- diff --git a/tests/components/samsungtv/test_device_trigger.py b/tests/components/samsungtv/test_device_trigger.py index 1420440ad4ca..92df6356f58f 100644 --- a/tests/components/samsungtv/test_device_trigger.py +++ b/tests/components/samsungtv/test_device_trigger.py @@ -1,6 +1,4 @@ """The tests for Samsung TV device triggers.""" -from unittest.mock import patch - import pytest from homeassistant.components import automation @@ -90,15 +88,11 @@ async def test_if_fires_on_turn_on_request( }, ) - with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): - await hass.services.async_call( - "media_player", - "turn_on", - {"entity_id": ENTITY_ID}, - blocking=True, - ) + await hass.services.async_call( + "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() - await hass.async_block_till_done() assert len(calls) == 2 assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 82bfbcce6759..24b3e7d4c7ea 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import DOMAIN, MediaPlayerEntityFeature from homeassistant.components.samsungtv.const import ( @@ -31,6 +32,7 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry @@ -77,7 +79,9 @@ REMOTE_CALL = { @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, snapshot: SnapshotAssertion +) -> None: """Test Samsung TV integration is setup.""" await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) await hass.async_block_till_done() @@ -96,6 +100,10 @@ async def test_setup(hass: HomeAssistant) -> None: DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) + # ensure deprecated_yaml issue is raised + issue = issue_registry.async_get_issue(SAMSUNGTV_DOMAIN, "deprecated_yaml") + assert issue == snapshot + async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) -> None: """Test import from yaml when the device is offline.""" diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 407d98186b17..27f6d7a8e51e 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -48,15 +48,11 @@ async def test_turn_on_trigger_device_id( }, ) - with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): - await hass.services.async_call( - "media_player", - "turn_on", - {"entity_id": ENTITY_ID}, - blocking=True, - ) + await hass.services.async_call( + "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 @@ -66,16 +62,17 @@ async def test_turn_on_trigger_device_id( calls.clear() - with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): + # Ensure WOL backup is called when trigger not present + with patch( + "homeassistant.components.samsungtv.media_player.send_magic_packet" + ) as mock_send_magic_packet: await hass.services.async_call( - "media_player", - "turn_on", - {"entity_id": ENTITY_ID}, - blocking=True, + "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True ) - await hass.async_block_till_done() + assert len(calls) == 0 + mock_send_magic_packet.assert_called() @pytest.mark.usefixtures("remoteencws", "rest_api") @@ -107,15 +104,11 @@ async def test_turn_on_trigger_entity_id( }, ) - with patch("homeassistant.components.samsungtv.media_player.send_magic_packet"): - await hass.services.async_call( - "media_player", - "turn_on", - {"entity_id": ENTITY_ID}, - blocking=True, - ) + await hass.services.async_call( + "media_player", "turn_on", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() - await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == ENTITY_ID assert calls[0].data["id"] == 0 From fe49861e26acc6143af3ffcb8d6f3024d36430a1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Mar 2023 09:07:46 +0100 Subject: [PATCH 0638/1058] Prepare MQTT common tests part4 (#90023) * Upd test_setting_attribute_via_mqtt_json_message * Update test_setting_attribute_with_template * Update test_update_with_json_attrs_not_dict * Update test_update_with_json_attrs_bad_json --- .../mqtt/test_alarm_control_panel.py | 16 ++++++++-------- tests/components/mqtt/test_binary_sensor.py | 19 ++++++++----------- tests/components/mqtt/test_button.py | 16 ++++++++-------- tests/components/mqtt/test_camera.py | 16 ++++++++-------- tests/components/mqtt/test_climate.py | 16 ++++++++-------- tests/components/mqtt/test_cover.py | 16 ++++++++-------- tests/components/mqtt/test_fan.py | 16 ++++++++-------- tests/components/mqtt/test_humidifier.py | 16 ++++++++-------- tests/components/mqtt/test_legacy_vacuum.py | 16 ++++++++-------- tests/components/mqtt/test_light.py | 16 ++++++++-------- tests/components/mqtt/test_light_json.py | 16 ++++++++-------- tests/components/mqtt/test_light_template.py | 16 ++++++++-------- tests/components/mqtt/test_lock.py | 16 ++++++++-------- tests/components/mqtt/test_number.py | 16 ++++++++-------- tests/components/mqtt/test_select.py | 16 ++++++++-------- tests/components/mqtt/test_sensor.py | 16 ++++++++-------- tests/components/mqtt/test_siren.py | 16 ++++++++-------- tests/components/mqtt/test_state_vacuum.py | 16 ++++++++-------- tests/components/mqtt/test_switch.py | 16 ++++++++-------- tests/components/mqtt/test_text.py | 16 ++++++++-------- tests/components/mqtt/test_update.py | 16 ++++++++-------- 21 files changed, 168 insertions(+), 171 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 0e760c69a8e8..020d361a052a 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -641,12 +641,12 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) @@ -666,12 +666,12 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) @@ -679,13 +679,13 @@ async def test_setting_attribute_with_template( async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, @@ -694,13 +694,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 344a33ad1acf..49417c3142e8 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -716,24 +716,21 @@ async def test_off_delay( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG, ) @@ -741,13 +738,13 @@ async def test_setting_attribute_with_template( async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG, @@ -756,13 +753,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 4902deaa4929..bfbee72bd1f2 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -206,11 +206,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) @@ -224,23 +224,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, DEFAULT_CONFIG, @@ -249,13 +249,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 5ba74b0999f2..4ff15f8d32de 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -196,11 +196,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) @@ -218,23 +218,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, DEFAULT_CONFIG, @@ -243,13 +243,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index a9c1ca6598ae..0ea9588992c0 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1487,11 +1487,11 @@ async def test_temperature_unit( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) @@ -1509,23 +1509,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, climate.DOMAIN, DEFAULT_CONFIG, @@ -1534,13 +1534,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, climate.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 9618ff7b047b..a20781cad12e 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -2591,11 +2591,11 @@ async def test_invalid_device_class( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) @@ -2613,23 +2613,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, DEFAULT_CONFIG, @@ -2638,13 +2638,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 0b6f32a2fdfe..4d13eb5c8bbf 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1708,11 +1708,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) @@ -1730,23 +1730,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, DEFAULT_CONFIG, @@ -1755,13 +1755,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index f74e883c4a00..bba468a4ab30 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1061,11 +1061,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) @@ -1083,23 +1083,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, humidifier.DOMAIN, DEFAULT_CONFIG, @@ -1108,13 +1108,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, humidifier.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 17ae575dca3e..7bd03bd39290 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -670,11 +670,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) @@ -692,23 +692,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -717,13 +717,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 515c41b8bad4..ed57482f7fd9 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -2172,11 +2172,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -2194,23 +2194,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -2219,13 +2219,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index f9c1a637932a..9bf0ef7a7f3c 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -1921,11 +1921,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -1943,23 +1943,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -1968,13 +1968,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 944f6ad016ae..e2e1b127c1cb 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -889,11 +889,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -911,23 +911,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, @@ -936,13 +936,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 88b86ef153a2..a99ad745570a 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -691,11 +691,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) @@ -713,23 +713,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, lock.DOMAIN, DEFAULT_CONFIG, @@ -738,13 +738,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, lock.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index b4c032468ec1..b005b75a8ac7 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -484,11 +484,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) @@ -506,23 +506,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, DEFAULT_CONFIG, @@ -531,13 +531,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index f7ebd8fb7ff8..6e885f0bff7d 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -361,11 +361,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) @@ -383,23 +383,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, DEFAULT_CONFIG, @@ -408,13 +408,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index a76f7ec28978..eb1e8fbdeb62 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -868,11 +868,11 @@ async def test_valid_state_class( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -890,23 +890,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, DEFAULT_CONFIG, @@ -915,13 +915,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 252a9d13f61c..b288acdd15ba 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -570,11 +570,11 @@ async def test_custom_state_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) @@ -588,23 +588,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, DEFAULT_CONFIG, @@ -613,13 +613,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index e8622ceb9b26..5164a747c822 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -407,11 +407,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) @@ -429,23 +429,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, @@ -454,13 +454,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2, diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index a11155f39228..4d604247222a 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -332,11 +332,11 @@ async def test_custom_state_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) @@ -350,23 +350,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, DEFAULT_CONFIG, @@ -375,13 +375,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index ed2b2a2e2bf4..1477240740e5 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -383,11 +383,11 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG ) @@ -401,23 +401,23 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, text.DOMAIN, DEFAULT_CONFIG, @@ -426,13 +426,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, text.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index c324d7d7bf06..e300550e1382 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -467,32 +467,32 @@ async def test_custom_availability_payload( async def test_setting_attribute_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) async def test_setting_attribute_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN, DEFAULT_CONFIG ) async def test_update_with_json_attrs_not_dict( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, DEFAULT_CONFIG, @@ -501,13 +501,13 @@ async def test_update_with_json_attrs_not_dict( async def test_update_with_json_attrs_bad_json( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: 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_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, update.DOMAIN, DEFAULT_CONFIG, From 23f136e9d67a38d8ab43082488f481a4fde1c57b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 09:16:32 +0100 Subject: [PATCH 0639/1058] Add state translations for Siren entities (#89994) --- homeassistant/components/siren/strings.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index c8e60e91ce09..60d8843c1515 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -1,3 +1,17 @@ { - "title": "Siren" + "title": "Siren", + "entity_component": { + "_": { + "name": "[%key:component::siren::title%]", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "available_tones": { + "name": "Available tones" + } + } + } + } } From d8654400126d76e3a7721c3986dffef8d4aa3b97 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Mar 2023 09:19:20 +0100 Subject: [PATCH 0640/1058] Prepare MQTT common tests part3 (#90022) --- .../mqtt/test_alarm_control_panel.py | 95 +++++++-------- tests/components/mqtt/test_binary_sensor.py | 71 +++++------ tests/components/mqtt/test_button.py | 66 ++++++----- tests/components/mqtt/test_camera.py | 55 +++++---- tests/components/mqtt/test_climate.py | 88 +++++++------- tests/components/mqtt/test_common.py | 92 ++++++--------- tests/components/mqtt/test_cover.py | 84 ++++++------- tests/components/mqtt/test_device_tracker.py | 27 +++-- tests/components/mqtt/test_fan.py | 88 +++++++------- tests/components/mqtt/test_humidifier.py | 92 ++++++++------- tests/components/mqtt/test_init.py | 42 +++---- tests/components/mqtt/test_legacy_vacuum.py | 84 ++++++------- tests/components/mqtt/test_light.py | 110 +++++++++--------- tests/components/mqtt/test_light_json.py | 99 ++++++++-------- tests/components/mqtt/test_light_template.py | 105 +++++++++-------- tests/components/mqtt/test_lock.py | 87 +++++++------- tests/components/mqtt/test_number.py | 88 +++++++------- tests/components/mqtt/test_scene.py | 51 ++++---- tests/components/mqtt/test_select.py | 92 ++++++++------- tests/components/mqtt/test_sensor.py | 70 +++++------ tests/components/mqtt/test_siren.py | 87 +++++++------- tests/components/mqtt/test_state_vacuum.py | 88 +++++++------- tests/components/mqtt/test_switch.py | 88 +++++++------- tests/components/mqtt/test_text.py | 88 +++++++------- tests/components/mqtt/test_update.py | 55 +++++---- 25 files changed, 1023 insertions(+), 969 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 020d361a052a..a7e5678a3b74 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -1,6 +1,7 @@ """The tests the MQTT alarm control panel component.""" import copy import json +from typing import Any from unittest.mock import patch import pytest @@ -9,6 +10,7 @@ from homeassistant.components import alarm_control_panel, mqtt from homeassistant.components.mqtt.alarm_control_panel import ( MQTT_ALARM_ATTRIBUTES_BLOCKED, ) +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -58,7 +60,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -172,10 +173,7 @@ async def test_fail_setup_without_state_or_command_topic( hass: HomeAssistant, config, valid ) -> None: """Test for failing setup with no state or command topic.""" - assert ( - help_test_validate_platform_config(hass, alarm_control_panel.DOMAIN, config) - == valid - ) + assert help_test_validate_platform_config(hass, config) == valid @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -722,30 +720,35 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + alarm_control_panel.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one alarm per unique_id.""" - config = { - mqtt.DOMAIN: { - alarm_control_panel.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, alarm_control_panel.DOMAIN, config + hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN ) @@ -888,16 +891,14 @@ async def test_discovery_broken( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][alarm_control_panel.DOMAIN], topic, @@ -954,14 +955,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, - mqtt_mock_entry_with_yaml_config, - alarm_control_panel.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG ) @@ -1016,15 +1014,15 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, - tpl_par, - tpl_output, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, + tpl_par: str, + tpl_output: PublishPayloadType, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = alarm_control_panel.DOMAIN @@ -1032,7 +1030,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1056,10 +1054,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = alarm_control_panel.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 49417c3142e8..3e224a4136a7 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -3,6 +3,7 @@ import copy from datetime import datetime, timedelta import json from pathlib import Path +from typing import Any from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -43,7 +44,6 @@ from .test_common import ( help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -781,28 +781,33 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + binary_sensor.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one sensor per unique_id.""" - config = { - mqtt.DOMAIN: { - binary_sensor.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, config + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN ) @@ -909,18 +914,16 @@ async def test_discovery_update_binary_sensor_template( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][binary_sensor.DOMAIN], topic, @@ -1021,14 +1024,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, - mqtt_mock_entry_with_yaml_config, - binary_sensor.DOMAIN, - DEFAULT_CONFIG, + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) @@ -1162,10 +1162,13 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( assert state.state == STATE_UNAVAILABLE -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = binary_sensor.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index bfbee72bd1f2..cdb3d0fbf382 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -1,5 +1,6 @@ """The tests for the MQTT button platform.""" import copy +from typing import Any from unittest.mock import patch import pytest @@ -35,7 +36,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -277,29 +277,32 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + button.DOMAIN: [ + { + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one button per unique_id.""" - config = { - mqtt.DOMAIN: { - button.DOMAIN: [ - { - "name": "Test 1", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN) async def test_discovery_removal_button( @@ -497,13 +500,13 @@ async def test_valid_device_class( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = button.DOMAIN @@ -511,7 +514,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -533,10 +536,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = button.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 4ff15f8d32de..90020bce489f 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -33,7 +33,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -271,29 +270,32 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + camera.DOMAIN: [ + { + "name": "Test 1", + "topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one camera per unique_id.""" - config = { - mqtt.DOMAIN: { - camera.DOMAIN: [ - { - "name": "Test 1", - "topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN) async def test_discovery_removal_camera( @@ -394,12 +396,12 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG, ["test_topic"], @@ -440,10 +442,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = camera.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 0ea9588992c0..9a9a9d81e8ec 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1,6 +1,7 @@ """The tests for the mqtt climate component.""" import copy import json +from typing import Any from unittest.mock import call, patch import pytest @@ -54,7 +55,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1562,31 +1562,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + climate.DOMAIN: [ + { + "name": "Test 1", + "mode_state_topic": "test_topic1/state", + "mode_command_topic": "test_topic1/command", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "mode_state_topic": "test_topic2/state", + "mode_command_topic": "test_topic2/command", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one climate per unique_id.""" - config = { - mqtt.DOMAIN: { - climate.DOMAIN: [ - { - "name": "Test 1", - "mode_state_topic": "test_topic1/state", - "mode_command_topic": "test_topic1/command", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "mode_state_topic": "test_topic2/state", - "mode_command_topic": "test_topic2/command", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, climate.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, climate.DOMAIN) @pytest.mark.parametrize( @@ -1609,19 +1612,17 @@ async def test_unique_id( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, climate.DOMAIN, config, topic, @@ -1727,7 +1728,7 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" config = { @@ -1741,7 +1742,7 @@ async def test_entity_id_update_subscriptions( } await help_test_entity_id_update_subscriptions( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, climate.DOMAIN, config, ["test-topic", "avty-topic"], @@ -1919,13 +1920,13 @@ async def test_precision_whole( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = climate.DOMAIN @@ -1936,7 +1937,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -2016,10 +2017,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = climate.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 88d993ef5d61..f5a4648e34cd 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -17,6 +17,7 @@ from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.config_integration import PLATFORM_CONFIG_SCHEMA_BASE from homeassistant.components.mqtt.const import MQTT_DISCONNECTED from homeassistant.components.mqtt.mixins import MQTT_ATTRIBUTES_BLOCKED +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.config import async_log_exception from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -73,7 +74,7 @@ MQTT_YAML_SCHEMA = vol.Schema({mqtt.DOMAIN: PLATFORM_CONFIG_SCHEMA_BASE}) def help_test_validate_platform_config( - hass: HomeAssistant, domain: str, config: ConfigType + hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: """Test the schema validation.""" try: @@ -82,7 +83,7 @@ def help_test_validate_platform_config( return True except vol.Error as exc: # log schema exceptions - async_log_exception(exc, domain, config, hass) + async_log_exception(exc, mqtt.DOMAIN, config, hass) return False @@ -394,10 +395,10 @@ async def help_test_default_availability_list_single( {"topic": "availability-topic1"}, ] config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - help_test_validate_platform_config(hass, domain, config) + help_test_validate_platform_config(hass, config) assert ( - f"Invalid config for [{domain}]: two or more values in the same group of exclusion 'availability'" + "Invalid config for [mqtt]: two or more values in the same group of exclusion 'availability'" in caplog.text ) @@ -698,14 +699,12 @@ async def help_test_discovery_update_attr( async def help_test_unique_id( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, - config: ConfigType, ) -> None: """Test unique id option only creates one entity per unique_id.""" - assert await async_setup_component(hass, mqtt.DOMAIN, config) + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() assert len(hass.states.async_entity_ids(domain)) == 1 @@ -854,15 +853,14 @@ async def help_test_discovery_broken( async def help_test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, topic: str, value: Any, attribute: str | None = None, attribute_value: Any = None, - init_payload: str | None = None, + init_payload: tuple[str, str] | None = None, skip_raw_test: bool = False, ) -> None: """Test handling of incoming encoded payload.""" @@ -929,13 +927,17 @@ async def help_test_encoding_subscribable_topics( init_payload_value_utf8 = init_payload[1].encode("utf-8") init_payload_value_utf16 = init_payload[1].encode("utf-16") - await hass.async_block_till_done() - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {domain: [config1, config2, config3]}} + await mqtt_mock_entry_no_yaml_config() + async_fire_mqtt_message( + hass, f"homeassistant/{domain}/item1/config", json.dumps(config1) + ) + async_fire_mqtt_message( + hass, f"homeassistant/{domain}/item2/config", json.dumps(config2) + ) + async_fire_mqtt_message( + hass, f"homeassistant/{domain}/item3/config", json.dumps(config3) ) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() expected_result = attribute_value or value @@ -1124,7 +1126,7 @@ async def help_test_entity_device_info_update( async def help_test_entity_id_update_subscriptions( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, topics: list[str] | None = None, @@ -1142,13 +1144,10 @@ async def help_test_entity_id_update_subscriptions( assert len(topics) > 0 entity_registry = er.async_get(hass) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, + mqtt_mock = await help_setup_component( + hass, mqtt_mock_entry_no_yaml_config, domain, config, True ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + assert mqtt_mock is not None state = hass.states.get(f"{domain}.test") assert state is not None @@ -1632,7 +1631,7 @@ async def help_test_entity_category( async def help_test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -1642,7 +1641,7 @@ async def help_test_publishing_with_custom_encoding( payload: str, template: str | None, tpl_par: str = "value", - tpl_output: str | None = None, + tpl_output: PublishPayloadType = None, ) -> None: """Test a service with publishing MQTT payload with different encoding.""" # prepare config for tests @@ -1676,14 +1675,16 @@ async def help_test_publishing_with_custom_encoding( if parameters: service_data[test_id].update(parameters) - # setup test entities - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {domain: setup_config}}, - ) + # setup test entities using discovery + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + item: int = 0 + for component_config in setup_config: + conf = json.dumps(component_config) + item += 1 + async_fire_mqtt_message( + hass, f"homeassistant/{domain}/component_{item}/config", conf + ) await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() # 1) test with default encoding await hass.services.async_call( @@ -1692,6 +1693,7 @@ async def help_test_publishing_with_custom_encoding( service_data["test1"], blocking=True, ) + await hass.async_block_till_done() mqtt_mock.async_publish.assert_any_call("cmd/test1", str(payload), 0, False) mqtt_mock.async_publish.reset_mock() @@ -1816,8 +1818,7 @@ async def help_test_reloadable( # We should call await mqtt.async_setup_entry(hass, entry) when async_setup # is removed (this is planned with #87987). Until then we set up the mqtt component # to test reload after the async_setup setup has set the initial config - await async_setup_component(hass, mqtt.DOMAIN, old_config) - await hass.async_block_till_done() + await help_setup_component(hass, None, domain, old_config, use_discovery=False) assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") @@ -1852,23 +1853,6 @@ async def help_test_reloadable( assert hass.states.get(f"{domain}.test_new_3") -async def help_test_setup_manual_entity_from_yaml( - hass: HomeAssistant, config: ConfigType -) -> None: - """Help to test setup from yaml through configuration entry.""" - # until `async_setup` does the initial config setup, we need to use - # async_setup_component to test with other yaml config - assert await async_setup_component(hass, mqtt.DOMAIN, config) - # Mock config entry - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) - entry.add_to_hass(hass) - - with patch("paho.mqtt.client.Client") as mock_client: - mock_client().connect = lambda *args: 0 - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - async def help_test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading the MQTT config entry.""" mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] @@ -1892,9 +1876,9 @@ async def help_test_unload_config_entry_with_platform( config_setup: dict[str, dict[str, Any]] = copy.deepcopy(config) config_setup[mqtt.DOMAIN][domain]["name"] = "config_setup" config_name = config_setup - # To be replaced with entry setup when `async_setup` is removed. - assert await async_setup_component(hass, mqtt.DOMAIN, config_setup) - await hass.async_block_till_done() + await help_setup_component( + hass, mqtt_mock_entry_no_yaml_config, domain, config_setup + ) # prepare setup through discovery discovery_setup = copy.deepcopy(config[mqtt.DOMAIN][domain]) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index a20781cad12e..a09edcd25e04 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,4 +1,5 @@ """The tests for the MQTT cover platform.""" +from typing import Any from unittest.mock import patch import pytest @@ -69,7 +70,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -2666,29 +2666,32 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique_id option only creates one cover per id.""" - config = { - mqtt.DOMAIN: { - cover.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN) async def test_discovery_removal_cover( @@ -2787,11 +2790,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) @@ -3469,13 +3472,13 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = cover.DOMAIN @@ -3484,7 +3487,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -3517,18 +3520,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][cover.DOMAIN], topic, @@ -3539,10 +3540,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = cover.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index bc3371f6e519..a8c45f8cd75d 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -4,17 +4,13 @@ from unittest.mock import patch import pytest from homeassistant.components import device_tracker, mqtt -from homeassistant.components.device_tracker import legacy from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .test_common import ( - help_test_setting_blocked_attribute_via_mqtt_json_message, - help_test_setup_manual_entity_from_yaml, -) +from .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator @@ -589,18 +585,21 @@ async def test_setting_blocked_attribute_via_mqtt_json_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + device_tracker.DOMAIN: {"name": "jan", "state_topic": "/location/jan"} + } + } + ], +) async def test_setup_with_modern_schema( - hass: HomeAssistant, mock_device_tracker_conf: list[legacy.Device] + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setup using the modern schema.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "jan" entity_id = f"{device_tracker.DOMAIN}.{dev_id}" - topic = "/location/jan" - - config = { - mqtt.DOMAIN: {device_tracker.DOMAIN: {"name": dev_id, "state_topic": topic}} - } - - await help_test_setup_manual_entity_from_yaml(hass, config) - assert hass.states.get(entity_id) is not None diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 4d13eb5c8bbf..41ff43aba878 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1,5 +1,6 @@ """Test MQTT fans.""" import copy +from typing import Any from unittest.mock import patch import pytest @@ -56,7 +57,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1371,12 +1371,11 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][fan.DOMAIN]) @@ -1386,8 +1385,7 @@ async def test_encoding_subscribable_topics( config[CONF_OSCILLATION_COMMAND_TOPIC] = "fan/some_oscillation_command_topic" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, fan.DOMAIN, config, topic, @@ -1779,31 +1777,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + fan.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique_id option only creates one fan per id.""" - config = { - mqtt.DOMAIN: { - fan.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN) async def test_discovery_removal_fan( @@ -1903,11 +1904,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) @@ -1975,13 +1976,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = fan.DOMAIN @@ -1991,7 +1992,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -2013,10 +2014,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = fan.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index bba468a4ab30..89afe0a39720 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1,5 +1,6 @@ """Test MQTT humidifiers.""" import copy +from typing import Any from unittest.mock import patch import pytest @@ -58,7 +59,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -752,12 +752,11 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][humidifier.DOMAIN]) @@ -765,8 +764,7 @@ async def test_encoding_subscribable_topics( config[CONF_MODE_COMMAND_TOPIC] = "humidifier/some_mode_command_topic" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, config, topic, @@ -1136,33 +1134,36 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + humidifier.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "target_humidity_command_topic": "humidity-command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "target_humidity_command_topic": "humidity-command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique_id option only creates one fan per id.""" - config = { - mqtt.DOMAIN: { - humidifier.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "target_humidity_command_topic": "humidity-command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "target_humidity_command_topic": "humidity-command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN) async def test_discovery_removal_humidifier( @@ -1274,11 +1275,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) @@ -1339,13 +1340,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = humidifier.DOMAIN @@ -1355,7 +1356,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1377,10 +1378,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = humidifier.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 22090064280a..9fbca57e3a91 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -42,7 +42,7 @@ from homeassistant.util.dt import utcnow from .test_common import ( help_test_entry_reload_with_new_config, - help_test_setup_manual_entity_from_yaml, + help_test_validate_platform_config, ) from tests.common import ( @@ -1867,8 +1867,7 @@ async def test_setup_manual_mqtt_with_platform_key( } } } - with pytest.raises(AssertionError): - await help_test_setup_manual_entity_from_yaml(hass, config) + help_test_validate_platform_config(hass, config) assert ( "Invalid config for [mqtt]: [platform] is an invalid option for [mqtt]" in caplog.text @@ -1881,8 +1880,7 @@ async def test_setup_manual_mqtt_with_invalid_config( ) -> None: """Test set up a manual MQTT item with an invalid config.""" config = {mqtt.DOMAIN: {"light": {"name": "test"}}} - with pytest.raises(AssertionError): - await help_test_setup_manual_entity_from_yaml(hass, config) + help_test_validate_platform_config(hass, config) assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']." " Got None. (See ?, line ?)" in caplog.text @@ -1895,7 +1893,7 @@ async def test_setup_manual_mqtt_empty_platform( ) -> None: """Test set up a manual MQTT platform without items.""" config: ConfigType = {mqtt.DOMAIN: {"light": []}} - await help_test_setup_manual_entity_from_yaml(hass, config) + help_test_validate_platform_config(hass, config) assert "voluptuous.error.MultipleInvalid" not in caplog.text @@ -3407,12 +3405,12 @@ async def test_disabling_and_enabling_entry( ) async def test_setup_manual_items_with_unique_ids( hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - hass_config: ConfigType, unique: bool, ) -> None: """Test setup manual items is generating unique id's.""" - await help_test_setup_manual_entity_from_yaml(hass, hass_config) + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("light.test1") is not None assert (hass.states.get("light.test2") is not None) == unique @@ -3480,19 +3478,20 @@ async def test_remove_unknown_conf_entry_options( ], ) async def test_link_config_entry( - hass: HomeAssistant, hass_config: ConfigType, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test manual and dynamically setup entities are linked to the config entry.""" + # set up manual item + await mqtt_mock_entry_no_yaml_config() + + # set up item through discovery config_discovery = { "name": "test_discovery", "unique_id": "test_discovery_unique456", "command_topic": "test-topic_discovery", } - - # set up manual item - await help_test_setup_manual_entity_from_yaml(hass, hass_config) - - # set up item through discovery async_fire_mqtt_message( hass, "homeassistant/light/bla/config", json.dumps(config_discovery) ) @@ -3541,18 +3540,3 @@ async def test_link_config_entry( ) await hass.async_block_till_done() assert _check_entities() == 2 - - -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) -@pytest.mark.parametrize( - "config_manual", - [ - {"mqtt": {"sensor": []}}, - {"mqtt": {"broker": "test"}}, - ], -) -async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, config_manual: ConfigType -) -> None: - """Test setup with empty platform keys.""" - await help_test_setup_manual_entity_from_yaml(hass, config_manual) diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 7bd03bd39290..42077fee0a70 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -1,6 +1,7 @@ """The tests for the Legacy Mqtt vacuum platform.""" from copy import deepcopy import json +from typing import Any from unittest.mock import patch import pytest @@ -56,7 +57,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -745,29 +745,32 @@ async def test_discovery_update_attr( ) +@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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one vacuum per unique_id.""" - 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", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN) async def test_discovery_removal_vacuum( @@ -866,7 +869,7 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" config = { @@ -882,7 +885,7 @@ async def test_entity_id_update_subscriptions( } await help_test_entity_id_update_subscriptions( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, config, ["test-topic", "avty-topic"], @@ -964,13 +967,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = vacuum.DOMAIN @@ -985,7 +988,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1030,12 +1033,11 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" domain = vacuum.DOMAIN @@ -1056,8 +1058,7 @@ async def test_encoding_subscribable_topics( await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, config, topic, @@ -1068,8 +1069,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = vacuum.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.mqtttest") diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index ed57482f7fd9..7d0c0333b7ef 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -169,6 +169,7 @@ mqtt: """ import copy +from typing import Any from unittest.mock import call, patch import pytest @@ -186,6 +187,7 @@ from homeassistant.components.mqtt.light.schema_basic import ( CONF_XY_COMMAND_TOPIC, MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ( ATTR_ASSUMED_STATE, STATE_OFF, @@ -219,7 +221,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -2247,31 +2248,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one light per unique_id.""" - config = { - mqtt.DOMAIN: { - light.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN) async def test_discovery_removal_light( @@ -2859,11 +2863,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -2999,15 +3003,15 @@ async def test_max_mireds( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, - tpl_par, - tpl_output, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, + tpl_par: str, + tpl_output: PublishPayloadType, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = light.DOMAIN @@ -3019,7 +3023,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -3075,13 +3079,12 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, - init_payload, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, + init_payload: tuple[str, str] | None, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][light.DOMAIN]) @@ -3098,8 +3101,7 @@ async def test_encoding_subscribable_topics( await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, light.DOMAIN, config, topic, @@ -3118,13 +3120,13 @@ async def test_encoding_subscribable_topics( ) async def test_encoding_subscribable_topics_brightness( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, - init_payload, + topic: str, + value: str, + attribute: str, + attribute_value: int, + init_payload: tuple[str, str] | None, ) -> None: """Test handling of incoming encoded payload for a brightness only light.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][light.DOMAIN]) @@ -3132,8 +3134,7 @@ async def test_encoding_subscribable_topics_brightness( await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, light.DOMAIN, config, topic, @@ -3298,10 +3299,13 @@ async def test_sending_mqtt_xy_command_with_template( assert state.attributes["xy_color"] == (0.151, 0.343) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = light.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 9bf0ef7a7f3c..db39cea78443 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -79,6 +79,7 @@ light: brightness_scale: 99 """ import copy +from typing import Any from unittest.mock import call, patch import pytest @@ -87,6 +88,7 @@ from homeassistant.components import light, mqtt from homeassistant.components.mqtt.light.schema_basic import ( MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES, @@ -122,7 +124,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -1996,33 +1997,36 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: [ + { + "name": "Test 1", + "schema": "json", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "schema": "json", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one light per unique_id.""" - config = { - mqtt.DOMAIN: { - light.DOMAIN: [ - { - "name": "Test 1", - "schema": "json", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "schema": "json", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN) async def test_discovery_removal( @@ -2167,11 +2171,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -2247,15 +2251,15 @@ async def test_max_mireds( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, - tpl_par, - tpl_output, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, + tpl_par: str, + tpl_output: PublishPayloadType, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = light.DOMAIN @@ -2265,7 +2269,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -2303,13 +2307,12 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, - init_payload, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, + init_payload: tuple[str, str] | None, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][light.DOMAIN]) @@ -2324,8 +2327,7 @@ async def test_encoding_subscribable_topics( ] await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, light.DOMAIN, config, topic, @@ -2337,8 +2339,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = light.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index e2e1b127c1cb..c8018d4847fb 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -25,6 +25,7 @@ If your light doesn't support color temp feature, omit `color_temp_template`. If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ import copy +from typing import Any from unittest.mock import patch import pytest @@ -33,6 +34,7 @@ from homeassistant.components import light, mqtt from homeassistant.components.mqtt.light.schema_basic import ( MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) +from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES, @@ -67,7 +69,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -964,37 +965,40 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: [ + { + "name": "Test 1", + "schema": "template", + "state_topic": "test-topic", + "command_topic": "test_topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "schema": "template", + "state_topic": "test-topic2", + "command_topic": "test_topic2", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one light per unique_id.""" - config = { - mqtt.DOMAIN: { - light.DOMAIN: [ - { - "name": "Test 1", - "schema": "template", - "state_topic": "test-topic", - "command_topic": "test_topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "schema": "template", - "state_topic": "test-topic2", - "command_topic": "test_topic2", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN) async def test_discovery_removal( @@ -1127,11 +1131,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) @@ -1219,15 +1223,15 @@ async def test_max_mireds( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, - tpl_par, - tpl_output, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, + tpl_par: str, + tpl_output: PublishPayloadType, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = light.DOMAIN @@ -1237,7 +1241,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1269,12 +1273,11 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, init_payload, ) -> None: """Test handling of incoming encoded payload.""" @@ -1282,8 +1285,7 @@ async def test_encoding_subscribable_topics( config["state_template"] = "{{ value }}" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, light.DOMAIN, config, topic, @@ -1294,10 +1296,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = light.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index a99ad745570a..0c8b6680c55e 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -1,5 +1,5 @@ """The tests for the MQTT lock platform.""" -from pathlib import Path +from typing import Any from unittest.mock import patch import pytest @@ -50,7 +50,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -762,31 +761,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lock.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one lock per unique_id.""" - config = { - mqtt.DOMAIN: { - lock.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN) async def test_discovery_removal_lock( @@ -899,11 +901,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, lock.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG ) @@ -944,13 +946,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = lock.DOMAIN @@ -958,7 +960,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -988,18 +990,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, lock.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][lock.DOMAIN], topic, @@ -1009,12 +1009,13 @@ async def test_encoding_subscribable_topics( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_manual_entity_from_yaml( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = lock.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index b005b75a8ac7..eb2a64022846 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -1,5 +1,6 @@ """The tests for mqtt number component.""" import json +from typing import Any from unittest.mock import patch import pytest @@ -54,7 +55,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -559,31 +559,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one number per unique_id.""" - config = { - mqtt.DOMAIN: { - number.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN) async def test_discovery_removal_number( @@ -696,11 +699,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) @@ -938,13 +941,13 @@ async def test_mqtt_payload_out_of_range_error( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = NUMBER_DOMAIN @@ -952,7 +955,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -983,18 +986,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][number.DOMAIN], topic, @@ -1004,10 +1005,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = number.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 3662cd6a1cad..3da5fd4f36a7 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -19,7 +19,6 @@ from .test_common import ( help_test_discovery_update, help_test_discovery_update_unchanged, help_test_reloadable, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, ) @@ -148,29 +147,32 @@ async def test_custom_availability_payload( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + scene.DOMAIN: [ + { + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one scene per unique_id.""" - config = { - mqtt.DOMAIN: { - scene.DOMAIN: [ - { - "name": "Test 1", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, scene.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, scene.DOMAIN) async def test_discovery_removal_scene( @@ -252,10 +254,13 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = scene.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 6e885f0bff7d..3a639ecf08f8 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -1,6 +1,7 @@ """The tests for mqtt select component.""" import copy import json +from typing import Any from unittest.mock import patch import pytest @@ -46,7 +47,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -436,33 +436,36 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + select.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + "options": ["milk", "beer"], + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + "options": ["milk", "beer"], + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one select per unique_id.""" - config = { - mqtt.DOMAIN: { - select.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - "options": ["milk", "beer"], - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - "options": ["milk", "beer"], - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN) async def test_discovery_removal_select( @@ -573,11 +576,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) @@ -683,13 +686,13 @@ async def test_mqtt_payload_not_an_option_warning( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = select.DOMAIN @@ -698,7 +701,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -729,20 +732,18 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][select.DOMAIN]) config["options"] = ["milk", "beer"] await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, select.DOMAIN, config, topic, @@ -752,10 +753,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = select.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index eb1e8fbdeb62..6889069c8ca2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -3,6 +3,7 @@ import copy from datetime import datetime, timedelta import json from pathlib import Path +from typing import Any from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -56,7 +57,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -943,29 +943,32 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one sensor per unique_id.""" - config = { - mqtt.DOMAIN: { - sensor.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN) async def test_discovery_removal_sensor( @@ -1123,11 +1126,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -1378,18 +1381,16 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][sensor.DOMAIN], topic, @@ -1400,10 +1401,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = sensor.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index b288acdd15ba..9837a3cc8a6a 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -44,7 +44,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -641,31 +640,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + siren.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one siren per unique_id.""" - config = { - mqtt.DOMAIN: { - siren.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN) async def test_discovery_removal_siren( @@ -924,11 +926,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) @@ -976,13 +978,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with command templates and different encoding.""" domain = siren.DOMAIN @@ -991,7 +993,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -1021,18 +1023,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][siren.DOMAIN], topic, @@ -1042,10 +1042,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = siren.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 5164a747c822..a5c838131008 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -1,6 +1,7 @@ """The tests for the State vacuum Mqtt platform.""" from copy import deepcopy import json +from typing import Any from unittest.mock import patch import pytest @@ -53,7 +54,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_update_with_json_attrs_bad_json, help_test_update_with_json_attrs_not_dict, @@ -482,31 +482,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + vacuum.DOMAIN: [ + { + "schema": "state", + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "schema": "state", + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one vacuum per unique_id.""" - config = { - mqtt.DOMAIN: { - vacuum.DOMAIN: [ - { - "schema": "state", - "name": "Test 1", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "schema": "state", - "name": "Test 2", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN) async def test_discovery_removal_vacuum( @@ -605,11 +608,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) @@ -679,13 +682,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = vacuum.DOMAIN @@ -705,7 +708,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -746,18 +749,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN], topic, @@ -768,8 +769,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = vacuum.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.mqtttest") diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 4d604247222a..83580edf0039 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -1,5 +1,6 @@ """The tests for the MQTT switch platform.""" import copy +from typing import Any from unittest.mock import patch import pytest @@ -39,7 +40,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -403,31 +403,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + switch.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one switch per unique_id.""" - config = { - mqtt.DOMAIN: { - switch.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN) async def test_discovery_removal_switch( @@ -602,11 +605,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) @@ -653,13 +656,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = switch.DOMAIN @@ -667,7 +670,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -697,18 +700,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][switch.DOMAIN], topic, @@ -718,10 +719,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = switch.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index 1477240740e5..d12a03a9fa1a 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -1,6 +1,7 @@ """The tests for the MQTT text platform.""" from __future__ import annotations +from typing import Any from unittest.mock import patch import pytest @@ -38,7 +39,6 @@ from .test_common import ( 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_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -454,31 +454,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + text.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one text per unique_id.""" - config = { - mqtt.DOMAIN: { - text.DOMAIN: [ - { - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN) async def test_discovery_removal_text( @@ -627,11 +630,11 @@ async def test_entity_device_info_remove( async def test_entity_id_update_subscriptions( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock_entry_with_yaml_config, text.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG ) @@ -667,13 +670,13 @@ async def test_entity_debug_info_message( ) async def test_publishing_with_custom_encoding( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - service, - topic, - parameters, - payload, - template, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, ) -> None: """Test publishing MQTT payload with different encoding.""" domain = text.DOMAIN @@ -681,7 +684,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock_entry_with_yaml_config, + mqtt_mock_entry_no_yaml_config, caplog, domain, config, @@ -711,18 +714,16 @@ async def test_reloadable( ) async def test_encoding_subscribable_topics( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - topic, - value, - attribute, - attribute_value, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, ) -> None: """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock_entry_with_yaml_config, - caplog, + mqtt_mock_entry_no_yaml_config, text.DOMAIN, DEFAULT_CONFIG[mqtt.DOMAIN][text.DOMAIN], topic, @@ -732,10 +733,13 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = text.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index e300550e1382..200a3ca6dd86 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -34,7 +34,6 @@ from .test_common import ( help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, - help_test_setup_manual_entity_from_yaml, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -529,31 +528,34 @@ async def test_discovery_update_attr( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + update.DOMAIN: [ + { + "name": "Bear", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Milk", + "state_topic": "installed-topic", + "latest_version_topic": "latest-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) async def test_unique_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test unique id option only creates one update per unique_id.""" - config = { - mqtt.DOMAIN: { - update.DOMAIN: [ - { - "name": "Bear", - "state_topic": "installed-topic", - "latest_version_topic": "latest-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "name": "Milk", - "state_topic": "installed-topic", - "latest_version_topic": "latest-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - } - } - await help_test_unique_id( - hass, mqtt_mock_entry_with_yaml_config, update.DOMAIN, config - ) + await help_test_unique_id(hass, mqtt_mock_entry_no_yaml_config, update.DOMAIN) async def test_discovery_removal_update( @@ -670,10 +672,13 @@ async def test_entity_id_update_discovery_update( ) -async def test_setup_manual_entity_from_yaml(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry_no_yaml_config() platform = update.DOMAIN - await help_test_setup_manual_entity_from_yaml(hass, DEFAULT_CONFIG) assert hass.states.get(f"{platform}.test") From 43ce6f843c0d30a00346837e5b4058863cd0f095 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 21 Mar 2023 01:21:14 -0700 Subject: [PATCH 0641/1058] Update the calendar trigger based on PR feedback (#90017) --- homeassistant/components/calendar/trigger.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 7807539413b0..f8a6014e2618 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -64,7 +64,7 @@ class Timespan: """Return a new interval shifted by the specified offset.""" return Timespan(self.start + offset, self.end + offset) - def contains(self, trigger: datetime.datetime) -> bool: + def __contains__(self, trigger: datetime.datetime) -> bool: """Return true if the trigger time is within the time span.""" return self.start <= trigger < self.end @@ -83,7 +83,7 @@ class Timespan: return Timespan(self.end, max(self.end, now) + interval) def __str__(self) -> str: - """Return a compact string representation.""" + """Return a string representing the half open interval timespan.""" return f"[{self.start}, {self.end})" @@ -125,9 +125,10 @@ def queued_event_fetcher( for trigger_time, event in zip( map(get_trigger_time, active_events), active_events ): - if not offset_timespan.contains(trigger_time): + if trigger_time not in offset_timespan: continue results.append(QueuedCalendarEvent(trigger_time + offset, event)) + _LOGGER.debug( "Scan events @ %s%s found %s eligble of %s active", offset_timespan, From 292feb4e246ccf8de88475ada7b95260d52fda31 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 09:51:05 +0100 Subject: [PATCH 0642/1058] Enable inheritance checks on ExtraStoredData (#90021) --- homeassistant/components/zwave_js/update.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 33cb0a1c5a8b..70d12b22dedc 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -132,7 +132,7 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self._attr_device_info = get_device_info(driver, node) @property - def extra_restore_state_data(self) -> ExtraStoredData: + def extra_restore_state_data(self) -> ZWaveNodeFirmwareUpdateExtraStoredData: """Return ZWave Node Firmware Update specific state data to be restored.""" return ZWaveNodeFirmwareUpdateExtraStoredData(self._latest_version_firmware) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index c63fde19c8ed..9430158fae90 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -680,6 +680,7 @@ _RESTORE_ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="extra_restore_state_data", return_type=["ExtraStoredData", None], + check_return_type_inheritance=True, ), ] _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ From 04872f72eac497b4d00c52dc02d217023d6322f4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 10:32:13 +0100 Subject: [PATCH 0643/1058] Improve humidifier type hints in integrations (#90030) Fix humidifier type hints in integrations --- homeassistant/components/generic_hygrostat/humidifier.py | 2 +- homeassistant/components/xiaomi_miio/humidifier.py | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 8ed2711d7cd0..dfd6be14e6a5 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -436,7 +436,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): data = {ATTR_ENTITY_ID: self._switch_entity_id} await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) - async def async_set_mode(self, mode: str): + async def async_set_mode(self, mode: str) -> None: """Set new mode. This method must be run in the event loop and returns a coroutine. diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 50e0cd8c72d9..6fde33309e4e 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -1,6 +1,7 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier with humidifier entity.""" import logging import math +from typing import Any from miio.integrations.humidifier.deerma.airhumidifier_mjjsq import ( OperationMode as AirhumidifierMjjsqOperationMode, @@ -136,10 +137,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): """Get the current mode.""" return self._mode - async def async_turn_on( - self, - **kwargs, - ) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" result = await self._try_command( "Turning the miio device on failed.", self._device.on @@ -148,7 +146,7 @@ class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" result = await self._try_command( "Turning the miio device off failed.", self._device.off From 4836404288e8e8a733dfe434d4964e6c6427fcad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:10:12 +0100 Subject: [PATCH 0644/1058] Improve media_player type hints in integrations (#90029) Fix some media_player type hints --- homeassistant/components/fully_kiosk/media_player.py | 3 ++- homeassistant/components/group/media_player.py | 6 ++++-- homeassistant/components/gstreamer/media_player.py | 2 +- homeassistant/components/hdmi_cec/media_player.py | 5 ++++- homeassistant/components/heos/media_player.py | 2 +- homeassistant/components/horizon/media_player.py | 4 +++- homeassistant/components/itunes/media_player.py | 4 +++- homeassistant/components/jellyfin/media_player.py | 2 +- homeassistant/components/lg_netcast/media_player.py | 4 +++- homeassistant/components/mpd/media_player.py | 2 +- homeassistant/components/onkyo/media_player.py | 5 ++++- homeassistant/components/openhome/media_player.py | 2 +- homeassistant/components/panasonic_viera/media_player.py | 2 +- homeassistant/components/plex/media_player.py | 4 +++- homeassistant/components/roku/media_player.py | 4 ++-- homeassistant/components/roon/media_player.py | 5 ++++- homeassistant/components/samsungtv/media_player.py | 2 +- homeassistant/components/slimproto/media_player.py | 3 ++- homeassistant/components/sonos/media_player.py | 4 ++-- homeassistant/components/soundtouch/media_player.py | 7 +++++-- homeassistant/components/spotify/media_player.py | 6 ++++-- homeassistant/components/squeezebox/media_player.py | 2 +- homeassistant/components/universal/media_player.py | 6 ++++-- homeassistant/components/xbox/media_player.py | 2 +- homeassistant/components/yamaha/media_player.py | 4 +++- homeassistant/components/yamaha_musiccast/media_player.py | 2 +- 26 files changed, 62 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index ae6cf083ed11..0fcd8c3543fd 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -8,6 +8,7 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaPlayerEntity, MediaPlayerState, + MediaType, async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry @@ -42,7 +43,7 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 3766c64cae51..15be22ddfbff 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,6 +1,7 @@ """Platform allowing several media players to be grouped into one media player.""" from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress from typing import Any @@ -20,6 +21,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -207,7 +209,7 @@ class MediaPlayerGroup(MediaPlayerEntity): return self._name @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes for the media group.""" return {ATTR_ENTITY_ID: self._entities} @@ -298,7 +300,7 @@ class MediaPlayerGroup(MediaPlayerEntity): ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" data = { diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index cb6e6cee7213..04e91e43172d 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -100,7 +100,7 @@ class GstreamerDevice(MediaPlayerEntity): self._player.volume = volume async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" # Handle media_source diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index 25019ec6933c..df7df830fdb6 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -30,6 +30,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -105,7 +106,9 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity): self.send_keypress(KEY_STOP) self._attr_state = MediaPlayerState.IDLE - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Not supported.""" raise NotImplementedError() diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 4184e9f82b75..3147c1e16606 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -195,7 +195,7 @@ class HeosMediaPlayer(MediaPlayerEntity): @log_command_error("play media") async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index 3a05f09501fa..d91fe7019d60 100644 --- a/homeassistant/components/horizon/media_player.py +++ b/homeassistant/components/horizon/media_player.py @@ -142,7 +142,9 @@ class HorizonDevice(MediaPlayerEntity): else: self._attr_state = MediaPlayerState.PAUSED - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play media / switch to channel.""" if media_type == MediaType.CHANNEL: try: diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index c9b0e4a07af3..78fd8b2a5b65 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -380,7 +380,9 @@ class ItunesDevice(MediaPlayerEntity): response = self.client.previous() self.update_state(response) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Send the play_media command to the media player.""" if media_type == MediaType.PLAYLIST: response = self.client.play_playlist(media_id) diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 3b3c8fbdf52f..32ca1d59d717 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -262,7 +262,7 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE def play_media( - self, media_type: str, media_id: str, **kwargs: dict[str, Any] + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" self.coordinator.api_client.jellyfin.remote_play_media( diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 1af16a904d83..2074966e1e7b 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -260,7 +260,9 @@ class LgTVDevice(MediaPlayerEntity): """Send the previous track command.""" self.send_command(LG_COMMAND.REWIND) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Tune to channel.""" if media_type != MediaType.CHANNEL: raise ValueError(f"Invalid media type: {media_type}") diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index fd783e0975bd..7395777320c0 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -435,7 +435,7 @@ class MpdDevice(MediaPlayerEntity): self._muted = mute async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the media player the command for playing a playlist.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index a12f2bc79869..4d6d0f6965da 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -14,6 +14,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall @@ -394,7 +395,9 @@ class OnkyoDevice(MediaPlayerEntity): source = self._reverse_mapping[source] self.command(f"input-selector {source}") - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play radio station by preset number.""" source = self._reverse_mapping[self._attr_source] if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index ef30d37bdcd6..68357c862c43 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -211,7 +211,7 @@ class OpenhomeDevice(MediaPlayerEntity): @catch_request_errors() async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 14c440f0ec13..8b676f37c269 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -185,7 +185,7 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): await self._remote.async_send_key(Keys.rewind) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" if media_source.is_media_source_id(media_id): diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 6fe6d641a835..c1a3ac5bd314 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -479,7 +479,9 @@ class PlexMediaPlayer(MediaPlayerEntity): if self.device and "playback" in self._device_protocol_capabilities: self.device.skipPrevious(self._active_media_plexapi_type) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" if not (self.device and "playback" in self._device_protocol_capabilities): raise HomeAssistantError( diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index b09ddb7ef7d7..b0191f605d14 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -252,7 +252,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def source_list(self) -> list: + def source_list(self) -> list[str]: """List of available input sources.""" return ["Home"] + sorted( app.name for app in self.coordinator.data.apps if app.name is not None @@ -353,7 +353,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): @roku_exception_handler() async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media from a URL or file, launch an application, or tune to a channel.""" extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {} diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index d87c6f31371c..307765da5cf1 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -12,6 +12,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, RepeatMode, ) from homeassistant.config_entries import ConfigEntry @@ -394,7 +395,9 @@ class RoonDevice(MediaPlayerEntity): raise ValueError(f"Unsupported repeat mode: {repeat}") self._server.roonapi.repeat(self.output_id, REPEAT_MODE_MAPPING_TO_ROON[repeat]) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Send the play_media command to the media player.""" _LOGGER.debug("Playback request for %s / %s", media_type, media_id) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 4e66a9c1d246..302d9c4915df 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -444,7 +444,7 @@ class SamsungTVDevice(MediaPlayerEntity): await self._async_send_keys(["KEY_CHDOWN"]) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Support changing a channel.""" if media_type == MediaType.APP: diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 993ed9571a9d..597ed50f4285 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -15,6 +15,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry @@ -175,7 +176,7 @@ class SlimProtoPlayer(MediaPlayerEntity): await self.player.power(False) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" to_send_media_type: str | None = media_type diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 22517b93991f..fbd74e57742f 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -303,7 +303,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return PLAY_MODES[self.media.play_mode][0] @property - def repeat(self) -> str | None: + def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" sonos_repeat = PLAY_MODES[self.media.play_mode][1] return SONOS_TO_REPEAT[sonos_repeat] @@ -493,7 +493,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): @soco_error() def play_media( # noqa: C901 - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player. diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 17c197d692fa..111a13c2c906 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, async_process_play_media_url, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -282,7 +283,7 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" if media_source.is_media_source_id(media_id): @@ -295,7 +296,9 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): partial(self.play_media, media_type, media_id, **kwargs) ) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play a piece of media.""" _LOGGER.debug("Starting media with media_id: %s", media_id) if re.match(r"http?://", str(media_id)): diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 51dd645ec021..7c583eb5335f 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -281,7 +281,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity): return self._currently_playing.get("shuffle_state") @property - def repeat(self) -> str | None: + def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" if ( not self._currently_playing @@ -321,7 +321,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self.data.client.seek_track(int(position * 1000)) @spotify_exception_handler - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play media.""" media_type = media_type.removeprefix(MEDIA_PLAYER_PREFIX) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 22812f06ed89..5c6f45c6aeba 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -469,7 +469,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): await self._player.async_set_power(True) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Send the play_media command to the media player.""" index = None diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 9f6c9db416e4..21d741d34551 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -44,6 +44,8 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, + RepeatMode, ) from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.const import ( @@ -574,7 +576,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): await self._async_call_service(SERVICE_MEDIA_SEEK, data) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id} @@ -613,7 +615,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): data = {ATTR_MEDIA_SHUFFLE: shuffle} await self._async_call_service(SERVICE_SHUFFLE_SET, data, allow_override=True) - async def async_set_repeat(self, repeat: str) -> None: + async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" data = {ATTR_MEDIA_REPEAT: repeat} await self._async_call_service(SERVICE_REPEAT_SET, data, allow_override=True) diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py index 1d56cfc71c57..ab16afa9280a 100644 --- a/homeassistant/components/xbox/media_player.py +++ b/homeassistant/components/xbox/media_player.py @@ -205,7 +205,7 @@ class XboxMediaPlayer(CoordinatorEntity[XboxUpdateCoordinator], MediaPlayerEntit ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Launch an app on the Xbox.""" if media_id == "Home": diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index aeb38c0faac7..e2658c21f372 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -347,7 +347,9 @@ class YamahaDevice(MediaPlayerEntity): """Select input source.""" self.receiver.input = self._reverse_mapping.get(source, source) - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: """Play media from an ID. This exposes a pass through for various input sources in the diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 01e5e1b8986b..05518a6c3c93 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -263,7 +263,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): ) async def async_play_media( - self, media_type: str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media.""" if media_source.is_media_source_id(media_id): From 0e1c76f81f8a1d8f10815ba8ca7f25a04b9fe02c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:39:42 +0100 Subject: [PATCH 0645/1058] Improve sensor type hints in integrations (#90031) * Improve sensor type hints in integrations * Improve --- homeassistant/components/awair/sensor.py | 6 +++--- homeassistant/components/emonitor/sensor.py | 2 +- homeassistant/components/entur_public_transport/sensor.py | 2 +- homeassistant/components/fints/sensor.py | 2 +- homeassistant/components/gtfs/sensor.py | 2 +- homeassistant/components/nsw_fuel_station/sensor.py | 2 +- homeassistant/components/statistics/sensor.py | 4 ++-- homeassistant/components/waze_travel_time/sensor.py | 3 ++- 8 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index dc48e0f92c37..f42a46999fbf 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -1,7 +1,7 @@ """Support for Awair sensors.""" from __future__ import annotations -from typing import cast +from typing import Any, cast from python_awair.air_data import AirData from python_awair.devices import AwairBaseDevice, AwairLocalDevice @@ -156,7 +156,7 @@ class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity): return round(state, 2) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the Awair Index alongside state attributes. The Awair Index is a subjective score ranging from 0-4 (inclusive) that @@ -178,7 +178,7 @@ class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity): https://docs.developer.getawair.com/?version=latest#awair-score-and-index """ sensor_type = self.entity_description.key - attrs: dict = {} + attrs: dict[str, Any] = {} if not self._air_data: return attrs if sensor_type in self._air_data.indices: diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index d5e677abcc97..dc7159001d80 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -123,6 +123,6 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): return self._paired_attr(self.entity_description.key) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, int]: """Return the device specific state attributes.""" return {"channel": self.channel_number} diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index f5a954b16d41..e109c25d3403 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -183,7 +183,7 @@ class EnturPublicTransportSensor(SensorEntity): return self._state @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" self._attributes[ATTR_STOP_ID] = self._stop return self._attributes diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 6ef0467f7b65..479e59d9cdf2 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -272,7 +272,7 @@ class FinTsHoldingsAccount(SensorEntity): self._attr_native_value = sum(h.total_value for h in self._holdings) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Additional attributes of the sensor. Lists each holding of the account with the current value. diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 6cf1a6d46040..77e1d0f7d33f 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -568,7 +568,7 @@ class GTFSDepartureSensor(SensorEntity): return self._available @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return self._attributes diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 6ebbccc44667..7106b4877860 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -117,7 +117,7 @@ class StationPriceSensor( return prices.get((self._station_id, self._fuel_type)) @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, int | str]: """Return the state attributes of the device.""" return { ATTR_STATION_ID: self._station_id, diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 9a87129e5d11..078eb59fe723 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -7,7 +7,7 @@ import contextlib from datetime import datetime, timedelta import logging import statistics -from typing import Any, Literal, cast +from typing import Any, cast import voluptuous as vol @@ -410,7 +410,7 @@ class StatisticsSensor(SensorEntity): return None @property - def state_class(self) -> Literal[SensorStateClass.MEASUREMENT] | None: + def state_class(self) -> SensorStateClass | None: """Return the state class of this entity.""" if self._state_characteristic in STATS_NOT_A_NUMBER: return None diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index f69f9a019fc7..ecbf3e9e12a0 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from WazeRouteCalculator import WazeRouteCalculator, WRCError @@ -112,7 +113,7 @@ class WazeTravelTime(SensorEntity): return None @property - def extra_state_attributes(self) -> dict | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the last update.""" if self._waze_data.duration is None: return None From 86b43544779b71ea4a03e5df1047377e65a7973f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:40:06 +0100 Subject: [PATCH 0646/1058] Improve native_value type hints in integrations (#90033) --- homeassistant/components/fivem/sensor.py | 4 +-- .../components/fully_kiosk/sensor.py | 8 +++--- .../components/kostal_plenticore/sensor.py | 3 ++- homeassistant/components/metoffice/sensor.py | 3 ++- .../components/nissan_leaf/sensor.py | 8 +++--- homeassistant/components/pi_hole/sensor.py | 9 +++---- .../components/synology_dsm/sensor.py | 26 +++++++++++-------- homeassistant/components/tibber/sensor.py | 5 ++-- 8 files changed, 35 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index 31e23565a6f6..9afe5890162e 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -1,11 +1,11 @@ """The FiveM sensor platform.""" from dataclasses import dataclass -from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import FiveMEntity, FiveMEntityDescription from .const import ( @@ -73,6 +73,6 @@ class FiveMSensorEntity(FiveMEntity, SensorEntity): entity_description: FiveMSensorEntityDescription @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index cf7dd62decc1..60009eb6ae4d 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation 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 FullyKioskDataUpdateCoordinator @@ -30,7 +30,7 @@ def round_storage(value: int) -> float: class FullySensorEntityDescription(SensorEntityDescription): """Fully Kiosk Browser sensor description.""" - state_fn: Callable | None = None + state_fn: Callable[[int], float] | None = None SENSORS: tuple[FullySensorEntityDescription, ...] = ( @@ -130,7 +130,7 @@ class FullySensor(FullyKioskEntity, SensorEntity): super().__init__(coordinator) @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the state of the sensor.""" if (value := self.coordinator.data.get(self.entity_description.key)) is None: return None @@ -138,4 +138,4 @@ class FullySensor(FullyKioskEntity, SensorEntity): if self.entity_description.state_fn is not None: return self.entity_description.state_fn(value) - return value + return value # type: ignore[no-any-return] diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index f919d15d6b38..a9b9433c1b67 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -24,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -791,7 +792,7 @@ class PlenticoreDataSensor( return f"{self.platform_name} {self._sensor_name}" @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" if self.coordinator.data is None: # None is translated to STATE_UNKNOWN diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 544dabd018ad..3bf50525ca9e 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -207,7 +208,7 @@ class MetOfficeCurrentSensor( ) @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" value = None diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index 5b2f99b997b6..cd3524eaf879 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -3,14 +3,12 @@ from __future__ import annotations import logging -from voluptuous.validators import Number - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE, UnitOfLength 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 homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -63,11 +61,11 @@ class LeafBatterySensor(LeafEntity, SensorEntity): return f"{self.car.leaf.nickname} Charge" @property - def native_value(self) -> Number | None: + def native_value(self) -> StateType: """Battery state percentage.""" if self.car.data[DATA_BATTERY] is None: return None - return round(self.car.data[DATA_BATTERY]) + return round(self.car.data[DATA_BATTERY]) # type: ignore[no-any-return] @property def icon(self) -> str: diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index dbca86613778..5d36ba67e83a 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -1,8 +1,6 @@ """Support for getting statistical data from a Pi-hole system.""" from __future__ import annotations -from typing import Any - from hole import Hole from homeassistant.components.sensor import SensorEntity, SensorEntityDescription @@ -10,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PiHoleEntity @@ -113,9 +112,9 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): self._attr_unique_id = f"{self._server_unique_id}/{description.name}" @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the state of the device.""" try: - return round(self.api.data[self.entity_description.key], 2) + return round(self.api.data[self.entity_description.key], 2) # type: ignore[no-any-return] except TypeError: - return self.api.data[self.entity_description.key] + return self.api.data[self.entity_description.key] # type: ignore[no-any-return] diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 06bfd166bb5e..4031a921a7fd 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any from synology_dsm.api.core.utilization import SynoCoreUtilization from synology_dsm.api.dsm.information import SynoDSMInformation @@ -26,6 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import SynoApi @@ -349,7 +349,7 @@ class SynoDSMUtilSensor(SynoDSMSensor): """Representation a Synology Utilisation sensor.""" @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state.""" attr = getattr(self._api.utilisation, self.entity_description.key) if callable(attr): @@ -357,19 +357,23 @@ class SynoDSMUtilSensor(SynoDSMSensor): if attr is None: return None + result: StateType = attr # Data (RAM) if self.native_unit_of_measurement == UnitOfInformation.MEGABYTES: - return round(attr / 1024.0**2, 1) + result = round(attr / 1024.0**2, 1) + return result # Network if self.native_unit_of_measurement == UnitOfDataRate.KILOBYTES_PER_SECOND: - return round(attr / 1024.0, 1) + result = round(attr / 1024.0, 1) + return result # CPU load average if self.native_unit_of_measurement == ENTITY_UNIT_LOAD: - return round(attr / 100, 2) + result = round(attr / 100, 2) + return result - return attr + return result @property def available(self) -> bool: @@ -393,7 +397,7 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): super().__init__(api, coordinator, description, device_id) @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType: """Return the state.""" attr = getattr(self._api.storage, self.entity_description.key)(self._device_id) if attr is None: @@ -401,9 +405,9 @@ class SynoDSMStorageSensor(SynologyDSMDeviceEntity, SynoDSMSensor): # Data (disk space) if self.native_unit_of_measurement == UnitOfInformation.TERABYTES: - return round(attr / 1024.0**4, 2) + return round(attr / 1024.0**4, 2) # type: ignore[no-any-return] - return attr + return attr # type: ignore[no-any-return] class SynoDSMInfoSensor(SynoDSMSensor): @@ -421,7 +425,7 @@ class SynoDSMInfoSensor(SynoDSMSensor): self._last_boot: datetime | None = None @property - def native_value(self) -> Any | None: + def native_value(self) -> StateType | datetime: """Return the state.""" attr = getattr(self._api.information, self.entity_description.key) if attr is None: @@ -434,4 +438,4 @@ class SynoDSMInfoSensor(SynoDSMSensor): self._previous_uptime = attr return self._last_boot - return attr + return attr # type: ignore[no-any-return] diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 874ec5be6735..a2f1db7536fa 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -41,6 +41,7 @@ from homeassistant.helpers.device_registry import async_get as async_get_dev_reg from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get as async_get_entity_reg +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -426,9 +427,9 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity["TibberDataCoordinator"]) self._device_name = self._home_name @property - def native_value(self) -> Any: + def native_value(self) -> StateType: """Return the value of the sensor.""" - return getattr(self._tibber_home, self.entity_description.key) + return getattr(self._tibber_home, self.entity_description.key) # type: ignore[no-any-return] class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]): From 33e698d67f4ac3fe61635af1db77604ad3690d2b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:40:19 +0100 Subject: [PATCH 0647/1058] Improve notify type hints in integrations (#90034) --- homeassistant/components/command_line/notify.py | 3 ++- homeassistant/components/file/notify.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index c41e26c21bb9..412456ff6e5f 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import subprocess +from typing import Any import voluptuous as vol @@ -46,7 +47,7 @@ class CommandLineNotificationService(BaseNotificationService): self.command = command self._timeout = timeout - def send_message(self, message="", **kwargs) -> None: + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a command line.""" with subprocess.Popen( self.command, diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 4a0b4c11ca63..3238fe911029 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -2,7 +2,7 @@ from __future__ import annotations import os -from typing import TextIO +from typing import Any, TextIO import voluptuous as vol @@ -48,7 +48,7 @@ class FileNotificationService(BaseNotificationService): self.filename = filename self.add_timestamp = add_timestamp - def send_message(self, message="", **kwargs) -> None: + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a file.""" file: TextIO if not self.hass.config.config_dir: From 485a78e0cfece8bb489bbc2c0a37a5b94c613a90 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:40:33 +0100 Subject: [PATCH 0648/1058] Improve light type hints in integrations (#90035) * Improve light type hints in integrations * Improve --- homeassistant/components/homematic/light.py | 4 ++-- homeassistant/components/iaqualink/light.py | 2 +- homeassistant/components/knx/light.py | 4 ++-- homeassistant/components/tplink/light.py | 4 ++-- homeassistant/components/velbus/light.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py index 87f3dfb314ab..39e6df9d0ec0 100644 --- a/homeassistant/components/homematic/light.py +++ b/homeassistant/components/homematic/light.py @@ -68,9 +68,9 @@ class HMLight(HMDevice, LightEntity): return ColorMode.BRIGHTNESS @property - def supported_color_modes(self) -> set[ColorMode | str]: + def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" - color_modes: set[ColorMode | str] = set() + color_modes: set[ColorMode] = set() if "COLOR" in self._hmdevice.WRITENODE: color_modes.add(ColorMode.HS) diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 00c9445a3b5b..8b83f7019152 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -83,7 +83,7 @@ class HassAqualinkLight(AqualinkEntity, LightEntity): return self.dev.effect @property - def effect_list(self) -> list: + def effect_list(self) -> list[str]: """Return supported light effects.""" return list(self.dev.supported_effects) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index e4260f5e868b..f5ef8f61b845 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -256,7 +256,7 @@ class KNXLight(KnxEntity, LightEntity): return None @property - def color_mode(self) -> ColorMode | None: + def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self._device.supports_xyy_color: return ColorMode.XY @@ -276,7 +276,7 @@ class KNXLight(KnxEntity, LightEntity): return ColorMode.ONOFF @property - def supported_color_modes(self) -> set | None: + def supported_color_modes(self) -> set[ColorMode]: """Flag supported color modes.""" return {self.color_mode} diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 7bbde327e183..e4f91f282f6b 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -267,9 +267,9 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): return hue, saturation @property - def supported_color_modes(self) -> set[ColorMode | str] | None: + def supported_color_modes(self) -> set[ColorMode]: """Return list of available color modes.""" - modes: set[ColorMode | str] = set() + modes: set[ColorMode] = set() if self.device.is_variable_color_temp: modes.add(ColorMode.COLOR_TEMP) if self.device.is_color: diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index e89c81bc110c..ca00a3134ce4 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -109,7 +109,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity): self._attr_name = f"LED {self._channel.get_name()}" @property - def is_on(self) -> Any: + def is_on(self) -> bool: """Return true if the light is on.""" return self._channel.is_on() From 0467c8ff63698f248df82c7c15def81e6b170138 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 12:39:07 +0100 Subject: [PATCH 0649/1058] Add attribute state translations for oscillating fans (#89990) --- homeassistant/components/fan/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index e9a808e0ae44..cccf41346523 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -32,7 +32,11 @@ } }, "oscillating": { - "name": "Oscillating" + "name": "Oscillating", + "state": { + "true": "[%key:common::state::yes%}", + "false": "[%key:common::state::no%]" + } }, "percentage": { "name": "Speed" From 6d3c3ff4fb9eb3ed198fabf0e687c9cfac2d4f80 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 12:45:06 +0100 Subject: [PATCH 0650/1058] Add state translations for Select entities (#89995) --- homeassistant/components/select/strings.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 11a4ba9517fc..9080b940b2a5 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -14,5 +14,15 @@ "condition_type": { "selected_option": "Current {entity_name} selected option" } + }, + "entity_component": { + "_": { + "name": "[%key:component::select::title%]", + "state_attributes": { + "options": { + "name": "Options" + } + } + } } } From 2a0401366b1dd776a3a91c975853e5c3e8899a14 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 12:53:05 +0100 Subject: [PATCH 0651/1058] Add state translations for all day calendar attribute (#89988) --- homeassistant/components/calendar/strings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 2663408cfdbd..898953c18acd 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -9,7 +9,11 @@ }, "state_attributes": { "all_day": { - "name": "All day" + "name": "All day", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } }, "description": { "name": "Description" From f01f5e1d2a89656efa5ea855f09f3dc5e759c4b5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 13:25:19 +0100 Subject: [PATCH 0652/1058] Improve type hints in tuya vacuum (#90041) --- homeassistant/components/tuya/vacuum.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 27fe764b1e3c..7827fb061ead 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -190,9 +190,14 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._send_command([{"code": DPCode.SUCTION, "value": fan_speed}]) def send_command( - self, command: str, params: dict | list | None = None, **kwargs: Any + self, + command: str, + params: dict[str, Any] | list[Any] | None = None, + **kwargs: Any, ) -> None: """Send raw command.""" if not params: raise ValueError("Params cannot be omitted for Tuya vacuum commands") + if not isinstance(params, list): + raise TypeError("Params must be a list for Tuya vacuum commands") self._send_command([{"code": command, "value": params[0]}]) From 9092a76dbf44103fd416d2f72bd55cc463950ef9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 21 Mar 2023 13:26:03 +0100 Subject: [PATCH 0653/1058] Correct typing of pymodbus in modbus (#90039) --- homeassistant/components/modbus/modbus.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index b53cfda104ea..cb3501f3375c 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -390,12 +390,12 @@ class ModbusHub: def _pymodbus_call( self, unit: int | None, address: int, value: int | list[int], use_call: str - ) -> ModbusResponse: + ) -> ModbusResponse | None: """Call sync. pymodbus.""" kwargs = {"slave": unit} if unit else {} entry = self._pb_call[use_call] try: - result = entry.func(address, value, **kwargs) + result: ModbusResponse = entry.func(address, value, **kwargs) except ModbusException as exception_error: self._log_error(str(exception_error)) return None From 93efdc499131c6d9b0bb34bb31fd82dfbd541829 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 13:26:41 +0100 Subject: [PATCH 0654/1058] Improve switch and climate type hints in integrations (#90040) --- homeassistant/components/bsblan/climate.py | 2 +- homeassistant/components/rachio/switch.py | 2 +- homeassistant/components/switchbot/switch.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index fcff6a925e5f..cbc6dd00471b 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -129,7 +129,7 @@ class BSBLANClimate( return PRESET_ECO return PRESET_NONE - async def async_set_hvac_mode(self, hvac_mode: str) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" await self.async_set_data(hvac_mode=hvac_mode) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index bc27c0b2203a..85abb989b646 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -399,7 +399,7 @@ class RachioZone(RachioSwitch): return self._entity_picture @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" props = {ATTR_ZONE_NUMBER: self._zone_number, ATTR_ZONE_SUMMARY: self._summary} if self._shade_type: diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index 76214a4412fe..befbf00f8be8 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import switchbot @@ -61,7 +62,7 @@ class SwitchBotSwitch(SwitchbotSwitchedEntity, SwitchEntity, RestoreEntity): return self._device.is_on() @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { **super().extra_state_attributes, From 41ea8fa9b4e15c7332cf2f9e6fded10745228582 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Mar 2023 15:01:35 +0100 Subject: [PATCH 0655/1058] Guess media type when cast is playing media without media type (#90048) --- homeassistant/components/cast/media_player.py | 10 +++++++++- tests/components/cast/test_media_player.py | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 0540380bc994..b701890d85db 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -819,7 +819,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): return MediaType.MOVIE if media_status.media_is_musictrack: return MediaType.MUSIC - return None + + chromecast = self._get_chromecast() + if chromecast.cast_type in ( + pychromecast.const.CAST_TYPE_AUDIO, + pychromecast.const.CAST_TYPE_GROUP, + ): + return MediaType.MUSIC + + return MediaType.VIDEO @property def media_duration(self): diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index eea8c0508881..8001411ac714 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -1336,7 +1336,17 @@ async def test_entity_play_media_playlist( ) -async def test_entity_media_content_type(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("cast_type", "default_content_type"), + [ + (pychromecast.const.CAST_TYPE_AUDIO, "music"), + (pychromecast.const.CAST_TYPE_GROUP, "music"), + (pychromecast.const.CAST_TYPE_CHROMECAST, "video"), + ], +) +async def test_entity_media_content_type( + hass: HomeAssistant, cast_type, default_content_type +) -> None: """Test various content types.""" entity_id = "media_player.speaker" reg = er.async_get(hass) @@ -1344,6 +1354,7 @@ async def test_entity_media_content_type(hass: HomeAssistant) -> None: info = get_fake_chromecast_info() chromecast, _ = await async_setup_media_player_cast(hass, info) + chromecast.cast_type = cast_type _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) connection_status = MagicMock() @@ -1364,7 +1375,7 @@ async def test_entity_media_content_type(hass: HomeAssistant) -> None: media_status_cb(media_status) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.attributes.get("media_content_type") is None + assert state.attributes.get("media_content_type") == default_content_type media_status.media_is_tvshow = True media_status_cb(media_status) From 6f88fe93ef4bcbbe2911d57d00169fd38c255d1b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 21 Mar 2023 15:21:45 +0100 Subject: [PATCH 0656/1058] Only publish mqtt_statestream when MQTT is started (#89833) * Only publish mqtt_statestream when ha is started * also catch startup states and use event filter * Add check for MQTT to be available first * Make sure MQTT is available and started * Fix test * Improve test * Reset mock before assertung not called --- .../components/mqtt_statestream/__init__.py | 60 +++++++++---- .../components/mqtt_statestream/test_init.py | 84 ++++++++++++++++++- 2 files changed, 126 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index 5213f6754608..014257375430 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -1,19 +1,20 @@ """Publish simple item state changes via MQTT.""" import json +import logging import voluptuous as vol from homeassistant.components import mqtt from homeassistant.components.mqtt import valid_publish_topic -from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED +from homeassistant.core import Event, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, convert_include_exclude_filter, ) -from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.start import async_at_start from homeassistant.helpers.typing import ConfigType CONF_BASE_TOPIC = "base_topic" @@ -35,23 +36,31 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT state feed.""" - conf = config[DOMAIN] + # Make sure MQTT is available and the entry is loaded + if not hass.config_entries.async_entries( + mqtt.DOMAIN + ) or not await hass.config_entries.async_wait_component( + hass.config_entries.async_entries(mqtt.DOMAIN)[0] + ): + _LOGGER.error("MQTT integration is not available") + return False + + conf: ConfigType = config[DOMAIN] publish_filter = convert_include_exclude_filter(conf) - base_topic = conf.get(CONF_BASE_TOPIC) - publish_attributes = conf.get(CONF_PUBLISH_ATTRIBUTES) - publish_timestamps = conf.get(CONF_PUBLISH_TIMESTAMPS) + base_topic: str = conf[CONF_BASE_TOPIC] + publish_attributes: bool = conf[CONF_PUBLISH_ATTRIBUTES] + publish_timestamps: bool = conf[CONF_PUBLISH_TIMESTAMPS] if not base_topic.endswith("/"): base_topic = f"{base_topic}/" - async def _state_publisher(entity_id, old_state, new_state): - if new_state is None: - return - - if not publish_filter(entity_id): - return + async def _state_publisher(evt: Event) -> None: + entity_id: str = evt.data["entity_id"] + new_state: State = evt.data["new_state"] payload = new_state.state @@ -81,5 +90,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: encoded_val = json.dumps(val, cls=JSONEncoder) await mqtt.async_publish(hass, mybase + key, encoded_val, 1, True) - async_track_state_change(hass, MATCH_ALL, _state_publisher) + @callback + def _ha_started(hass: HomeAssistant) -> None: + @callback + def _event_filter(evt: Event) -> bool: + entity_id: str = evt.data["entity_id"] + new_state: State | None = evt.data["new_state"] + if new_state is None: + return False + if not publish_filter(entity_id): + return False + return True + + callback_handler = hass.bus.async_listen( + EVENT_STATE_CHANGED, _state_publisher, _event_filter + ) + + @callback + def _ha_stopping(_: Event) -> None: + callback_handler() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _ha_stopping) + + async_at_start(hass, _ha_started) + return True diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index fd3430e2c791..130d874cc509 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -1,22 +1,25 @@ """The tests for the MQTT statestream component.""" from unittest.mock import ANY, call +import pytest + import homeassistant.components.mqtt_statestream as statestream -from homeassistant.core import HomeAssistant, State +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.setup import async_setup_component -from tests.common import mock_state_change_event +from tests.common import MockEntity, MockEntityPlatform, mock_state_change_event from tests.typing import MqttMockHAClient async def add_statestream( - hass, + hass: HomeAssistant, base_topic=None, publish_attributes=None, publish_timestamps=None, publish_include=None, publish_exclude=None, -): +) -> bool: """Add a mqtt_statestream component.""" config = {} if base_topic: @@ -48,6 +51,59 @@ async def test_setup_succeeds_without_attributes( assert await add_statestream(hass, base_topic="pub") +async def test_setup_and_stop_waits_for_ha( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test the success of the setup with a valid base_topic.""" + e_id = "fake.entity" + + # HA is not running + hass.state = CoreState.not_running + + assert await add_statestream(hass, base_topic="pub") + await hass.async_block_till_done() + # Set a state of an entity + mock_state_change_event(hass, State(e_id, "on")) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Make sure 'on' was not published to pub/fake/entity/state + mqtt_mock.async_publish.assert_not_called() + + # HA is starting up + await hass.async_start() + await hass.async_block_till_done() + + # Change a state of an entity + mock_state_change_event(hass, State(e_id, "off")) + await hass.async_block_till_done() + await hass.async_block_till_done() + + mqtt_mock.async_publish.assert_called_with("pub/fake/entity/state", "off", 1, True) + assert mqtt_mock.async_publish.called + mqtt_mock.reset_mock() + + # HA is shutting down + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + # Change a state of an entity + mock_state_change_event(hass, State(e_id, "on")) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Make sure 'on' was not published to pub/fake/entity/state + mqtt_mock.async_publish.assert_not_called() + + +async def test_startup_no_mqtt( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test startup without MQTT support.""" + assert not await add_statestream(hass, base_topic="pub") + assert "MQTT integration is not available" in caplog.text + + async def test_setup_succeeds_with_attributes( hass: HomeAssistant, mqtt_mock: MqttMockHAClient ) -> None: @@ -78,6 +134,26 @@ async def test_state_changed_event_sends_message( # Make sure 'on' was published to pub/fake/entity/state mqtt_mock.async_publish.assert_called_with("pub/fake/entity/state", "on", 1, True) assert mqtt_mock.async_publish.called + mqtt_mock.async_publish.reset_mock() + + # Create a test entity and add it to hass + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id="1234") + await platform.async_add_entities([entity]) + + mqtt_mock.async_publish.assert_called_with( + "pub/test_domain/test_platform_1234/state", "unknown", 1, True + ) + mqtt_mock.async_publish.reset_mock() + + state = hass.states.get("test_domain.test_platform_1234") + assert state is not None + + # Now remove it, nothing should be published + hass.states.async_remove("test_domain.test_platform_1234") + await hass.async_block_till_done() + await hass.async_block_till_done() + mqtt_mock.async_publish.assert_not_called() async def test_state_changed_event_sends_message_and_timestamp( From 1303dd12e77ecb175b8b5acc19c2d7d92970ef65 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 15:21:56 +0100 Subject: [PATCH 0657/1058] Improve type hints in zha fan (#90042) --- homeassistant/components/zha/fan.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 13d63808b61f..5153d3c45673 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -278,10 +278,8 @@ class IkeaFan(BaseFan, ZhaEntity): """Return the number of speeds the fan supports.""" return int_states_in_range(IKEA_SPEED_RANGE) - async def async_set_percentage(self, percentage: int | None) -> None: - """Set the speed percenage of the fan.""" - if percentage is None: - percentage = 0 + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" fan_mode = math.ceil(percentage_to_ranged_value(IKEA_SPEED_RANGE, percentage)) await self._async_set_fan_mode(fan_mode) @@ -311,12 +309,17 @@ class IkeaFan(BaseFan, ZhaEntity): """Return the current preset mode.""" return IKEA_PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) - async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs) -> None: + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn the entity on.""" if percentage is None: - percentage = (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[ - PRESET_MODE_AUTO - ] + percentage = int( + (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[PRESET_MODE_AUTO] + ) await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: From 1895c82ffcde7eb6e481eff845e48f9c2bfd5462 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 15:52:30 +0100 Subject: [PATCH 0658/1058] Fix fritzbox TypedDict definition (#90043) --- homeassistant/components/fritzbox/model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index ea63ab983c12..3c3275e0ff00 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -8,7 +8,6 @@ from typing import TypedDict from pyfritzhome import FritzhomeDevice -@dataclass class ClimateExtraAttributes(TypedDict, total=False): """TypedDict for climates extra attributes.""" From c507ca1e665abffd9b8986f0974f74d35cde1653 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Mar 2023 15:53:51 +0100 Subject: [PATCH 0659/1058] Improve type hints in rachio switch (#90050) --- homeassistant/components/rachio/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 85abb989b646..c04a1a09f814 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -506,7 +506,7 @@ class RachioSchedule(RachioSwitch): return "mdi:water" if self.schedule_is_enabled else "mdi:water-off" @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" return { ATTR_SCHEDULE_SUMMARY: self._summary, From 616e6e6ae8b914c6992ae802d35767872d135b60 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 07:14:27 -1000 Subject: [PATCH 0660/1058] Fix missing length on context id and incorrect precision with MariaDB (dev only fix) (#90058) Fix missing length on context id column with MariaDB spotted by @dcoder42 The migration still worked as intented but the blob should have been a bit smaller. This only affects dev so there is no need for a backport --- homeassistant/components/recorder/migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index a4dea027d8cb..419ef7aa1173 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -549,7 +549,7 @@ def _apply_update( # noqa: C901 if dialect == SupportedDialect.MYSQL: timestamp_type = "DOUBLE PRECISION" context_bin_type = f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})" - if dialect == SupportedDialect.POSTGRESQL: + elif dialect == SupportedDialect.POSTGRESQL: timestamp_type = "DOUBLE PRECISION" context_bin_type = "BYTEA" else: From ed4e49a4c25dcb4a7bcdc34b56d501f85ed88605 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 18:41:27 +0100 Subject: [PATCH 0661/1058] Add translations for Counter (#89989) --- homeassistant/components/counter/strings.json | 29 +++++++++++++++++++ homeassistant/generated/integrations.json | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/counter/strings.json diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json new file mode 100644 index 000000000000..fb7d34edf484 --- /dev/null +++ b/homeassistant/components/counter/strings.json @@ -0,0 +1,29 @@ +{ + "title": "Counter", + "entity_component": { + "_": { + "name": "[%key:component::counter::title%]", + "state_attributes": { + "editable": { + "name": "UI-managed", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "initial": { + "name": "Initial value" + }, + "maximum": { + "name": "Maximum" + }, + "minimum": { + "name": "Minimum" + }, + "step": { + "name": "Step" + } + } + } + } +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8dbdd5a7851e..efd1899c5b0e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6490,7 +6490,6 @@ }, "helper": { "counter": { - "name": "Counter", "integration_type": "helper", "config_flow": false }, @@ -6573,6 +6572,7 @@ "alert", "aurora", "cert_expiry", + "counter", "cpuspeed", "demo", "derivative", From d20b07f3ac852c41cc337b643d6380862b0f04cf Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 21 Mar 2023 19:16:50 +0100 Subject: [PATCH 0662/1058] Bump reolink-aio to 0.5.6 (#90059) --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/number.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 1f776d13721d..7050ed61d504 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.5.5"] + "requirements": ["reolink-aio==0.5.6"] } diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index cab925d41fed..4a221e2ca9d7 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -63,7 +63,7 @@ NUMBER_ENTITIES = ( native_step=1, get_min_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["min"], get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"], - supported=lambda api, ch: api.supported(ch, "zoom"), + supported=lambda api, ch: api.supported(ch, "focus"), value=lambda api, ch: api.get_focus(ch), method=lambda api, ch, value: api.set_focus(ch, int(value)), ), diff --git a/requirements_all.txt b/requirements_all.txt index d7d30cc98711..4a06243b4104 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.5 +reolink-aio==0.5.6 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19e29a7c2fd9..f0fef72dca50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1594,7 +1594,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.5 +reolink-aio==0.5.6 # homeassistant.components.python_script restrictedpython==6.0 From 0f5c49c7be334709b4f0926731b4ed98ef460ef7 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Tue, 21 Mar 2023 16:42:44 -0300 Subject: [PATCH 0663/1058] Fix translation string for fan oscillation (#90045) Fix string --- homeassistant/components/fan/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index cccf41346523..b16d6da6df56 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -34,7 +34,7 @@ "oscillating": { "name": "Oscillating", "state": { - "true": "[%key:common::state::yes%}", + "true": "[%key:common::state::yes%]", "false": "[%key:common::state::no%]" } }, From d4cc4a343dd9585686e53aefb4708901ae3d5550 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 21 Mar 2023 21:33:33 +0100 Subject: [PATCH 0664/1058] Use has_template property from lib in Fritz!SmartHome (#89152) --- homeassistant/components/fritzbox/__init__.py | 11 ++--------- tests/components/fritzbox/test_init.py | 17 +---------------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index fc65ed96459c..38f0e375e874 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -from xml.etree.ElementTree import ParseError from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome.devicetypes.fritzhomeentitybase import FritzhomeEntityBase @@ -44,14 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_CONNECTIONS: fritz, } - try: - await hass.async_add_executor_job(fritz.update_templates) - except ParseError: - LOGGER.debug("Disable smarthome templates") - has_templates = False - else: - LOGGER.debug("Enable smarthome templates") - has_templates = True + has_templates = await hass.async_add_executor_job(fritz.has_templates) + LOGGER.debug("enable smarthome templates: %s", has_templates) coordinator = FritzboxDataUpdateCoordinator(hass, entry, has_templates) diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 3aa155863d53..28476d882730 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -2,7 +2,6 @@ from __future__ import annotations from unittest.mock import Mock, call, patch -from xml.etree.ElementTree import ParseError from pyfritzhome import LoginError import pytest @@ -170,7 +169,7 @@ async def test_coordinator_update_after_reboot( assert await hass.config_entries.async_setup(entry.entry_id) assert fritz().update_devices.call_count == 2 - assert fritz().update_templates.call_count == 2 + assert fritz().update_templates.call_count == 1 assert fritz().get_devices.call_count == 1 assert fritz().get_templates.call_count == 1 assert fritz().login.call_count == 2 @@ -270,17 +269,3 @@ async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> entries = hass.config_entries.async_entries() config_entry = entries[0] assert config_entry.state is ConfigEntryState.SETUP_ERROR - - -async def test_disable_smarthome_templates(hass: HomeAssistant, fritz: Mock) -> None: - """Test smarthome templates are disabled.""" - entry = MockConfigEntry( - domain=FB_DOMAIN, - data=MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], - unique_id="any", - ) - entry.add_to_hass(hass) - fritz().update_templates.side_effect = [ParseError(), ""] - - assert await hass.config_entries.async_setup(entry.entry_id) - assert fritz().update_templates.call_count == 1 From 980425508a14e6f61859c269f0fb060361f0c2d6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 22:31:55 +0100 Subject: [PATCH 0665/1058] Update twentemilieu to 1.0.0 (#90071) --- homeassistant/components/twentemilieu/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index f5745734f1a7..cfacc9072f2c 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "platinum", - "requirements": ["twentemilieu==0.6.1"] + "requirements": ["twentemilieu==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4a06243b4104..5395f1104ed9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2536,7 +2536,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==0.6.1 +twentemilieu==1.0.0 # homeassistant.components.twilio twilio==6.32.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0fef72dca50..265ec1eeab60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1797,7 +1797,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==0.6.1 +twentemilieu==1.0.0 # homeassistant.components.twilio twilio==6.32.0 From f98d6851541cc4c57e48847a48644cd3fb7410fb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 21 Mar 2023 22:32:41 +0100 Subject: [PATCH 0666/1058] Refactor WLED select tests (#89219) --- .../wled/snapshots/test_select.ambr | 431 ++++++++++++++++ tests/components/wled/test_select.py | 461 ++++-------------- 2 files changed, 522 insertions(+), 370 deletions(-) create mode 100644 tests/components/wled/snapshots/test_select.ambr diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr new file mode 100644 index 000000000000..05d61fc18cb2 --- /dev/null +++ b/tests/components/wled/snapshots/test_select.ambr @@ -0,0 +1,431 @@ +# serializer version: 1 +# name: test_color_palette_state[rgb-select.wled_rgb_light_live_override-2-live-called_with1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Live override', + 'icon': 'mdi:theater', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.wled_rgb_light_live_override', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_live_override-2-live-called_with1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wled_rgb_light_live_override', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:theater', + 'original_name': 'Live override', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': 'live_override', + 'unique_id': 'aabbccddeeff_live_override', + 'unit_of_measurement': None, + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_live_override-2-live-called_with1].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_segment_1_color_palette-Icefire-segment-called_with0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGB Light Segment 1 color palette', + 'icon': 'mdi:palette-outline', + 'options': list([ + 'Analogous', + 'April Night', + 'Autumn', + 'Based on Primary', + 'Based on Set', + 'Beach', + 'Beech', + 'Breeze', + 'C9', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Primary Color', + 'Rainbow', + 'Rainbow Bands', + 'Random Cycle', + 'Red & Blue', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Set Colors', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Tertiary', + 'Tiamat', + 'Vintage', + 'Yelblu', + 'Yellowout', + 'Yelmag', + ]), + }), + 'context': , + 'entity_id': 'select.wled_rgb_light_segment_1_color_palette', + 'last_changed': , + 'last_updated': , + 'state': 'Random Cycle', + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_segment_1_color_palette-Icefire-segment-called_with0].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Analogous', + 'April Night', + 'Autumn', + 'Based on Primary', + 'Based on Set', + 'Beach', + 'Beech', + 'Breeze', + 'C9', + 'Cloud', + 'Cyane', + 'Default', + 'Departure', + 'Drywet', + 'Fire', + 'Forest', + 'Grintage', + 'Hult', + 'Hult 64', + 'Icefire', + 'Jul', + 'Landscape', + 'Lava', + 'Light Pink', + 'Magenta', + 'Magred', + 'Ocean', + 'Orange & Teal', + 'Orangery', + 'Party', + 'Pastel', + 'Primary Color', + 'Rainbow', + 'Rainbow Bands', + 'Random Cycle', + 'Red & Blue', + 'Rewhi', + 'Rivendell', + 'Sakura', + 'Set Colors', + 'Sherbet', + 'Splash', + 'Sunset', + 'Sunset 2', + 'Tertiary', + 'Tiamat', + 'Vintage', + 'Yelblu', + 'Yellowout', + 'Yelmag', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.wled_rgb_light_segment_1_color_palette', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:palette-outline', + 'original_name': 'Segment 1 color palette', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddeeff_palette_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_color_palette_state[rgb-select.wled_rgb_light_segment_1_color_palette-Icefire-segment-called_with0].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddeeff', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGB Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.5', + 'via_device_id': None, + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_playlist-Playlist 2-playlist-called_with2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGBW Light Playlist', + 'icon': 'mdi:play-speed', + 'options': list([ + 'Playlist 1', + 'Playlist 2', + ]), + }), + 'context': , + 'entity_id': 'select.wled_rgbw_light_playlist', + 'last_changed': , + 'last_updated': , + 'state': 'Playlist 1', + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_playlist-Playlist 2-playlist-called_with2].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Playlist 1', + 'Playlist 2', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.wled_rgbw_light_playlist', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:play-speed', + 'original_name': 'Playlist', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddee11_playlist', + 'unit_of_measurement': None, + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_playlist-Playlist 2-playlist-called_with2].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:11', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddee11', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGBW Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.6b4', + 'via_device_id': None, + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_preset-Preset 2-preset-called_with3] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WLED RGBW Light Preset', + 'icon': 'mdi:playlist-play', + 'options': list([ + 'Preset 1', + 'Preset 2', + ]), + }), + 'context': , + 'entity_id': 'select.wled_rgbw_light_preset', + 'last_changed': , + 'last_updated': , + 'state': 'Preset 1', + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_preset-Preset 2-preset-called_with3].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Preset 1', + 'Preset 2', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.wled_rgbw_light_preset', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:playlist-play', + 'original_name': 'Preset', + 'platform': 'wled', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbccddee11_preset', + 'unit_of_measurement': None, + }) +# --- +# name: test_color_palette_state[rgbw-select.wled_rgbw_light_preset-Preset 2-preset-called_with3].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:11', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'esp8266', + 'id': , + 'identifiers': set({ + tuple( + 'wled', + 'aabbccddee11', + ), + }), + 'is_new': False, + 'manufacturer': 'WLED', + 'model': 'DIY light', + 'name': 'WLED RGBW Light', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.8.6b4', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index 4ef8fa5941e9..caf1fa248681 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -3,25 +3,20 @@ import json from unittest.mock import MagicMock import pytest +from syrupy.assertion import SnapshotAssertion from wled import Device as WLEDDevice, WLEDConnectionError, WLEDError -from homeassistant.components.select import ( - ATTR_OPTION, - ATTR_OPTIONS, - DOMAIN as SELECT_DOMAIN, -) +from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.components.wled.const import SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_ICON, SERVICE_SELECT_OPTION, STATE_UNAVAILABLE, STATE_UNKNOWN, - EntityCategory, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +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, load_fixture @@ -29,95 +24,102 @@ from tests.common import async_fire_time_changed, load_fixture pytestmark = pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "option", "method", "called_with"), + [ + ( + "rgb", + "select.wled_rgb_light_segment_1_color_palette", + "Icefire", + "segment", + {"segment_id": 1, "palette": "Icefire"}, + ), + ( + "rgb", + "select.wled_rgb_light_live_override", + "2", + "live", + {"live": 2}, + ), + ( + "rgbw", + "select.wled_rgbw_light_playlist", + "Playlist 2", + "playlist", + {"playlist": "Playlist 2"}, + ), + ( + "rgbw", + "select.wled_rgbw_light_preset", + "Preset 2", + "preset", + {"preset": "Preset 2"}, + ), + ], +) async def test_color_palette_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_wled: MagicMock, + entity_id: str, + option: str, + method: str, + called_with: dict[str, int | str], ) -> None: """Test the creation and values of the WLED selects.""" # First segment of the strip - assert (state := hass.states.get("select.wled_rgb_light_segment_1_color_palette")) - assert state.attributes.get(ATTR_ICON) == "mdi:palette-outline" - assert state.attributes.get(ATTR_OPTIONS) == [ - "Analogous", - "April Night", - "Autumn", - "Based on Primary", - "Based on Set", - "Beach", - "Beech", - "Breeze", - "C9", - "Cloud", - "Cyane", - "Default", - "Departure", - "Drywet", - "Fire", - "Forest", - "Grintage", - "Hult", - "Hult 64", - "Icefire", - "Jul", - "Landscape", - "Lava", - "Light Pink", - "Magenta", - "Magred", - "Ocean", - "Orange & Teal", - "Orangery", - "Party", - "Pastel", - "Primary Color", - "Rainbow", - "Rainbow Bands", - "Random Cycle", - "Red & Blue", - "Rewhi", - "Rivendell", - "Sakura", - "Set Colors", - "Sherbet", - "Splash", - "Sunset", - "Sunset 2", - "Tertiary", - "Tiamat", - "Vintage", - "Yelblu", - "Yellowout", - "Yelmag", - ] - assert state.state == "Random Cycle" + assert (state := hass.states.get(entity_id)) + assert state == snapshot - assert ( - entry := entity_registry.async_get( - "select.wled_rgb_light_segment_1_color_palette" - ) - ) - assert entry.unique_id == "aabbccddeeff_palette_1" - assert entry.entity_category is EntityCategory.CONFIG + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + + method_mock = getattr(mock_wled, method) -async def test_color_palette_segment_change_state( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test the option change of state of the WLED segments.""" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Icefire", - }, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: option}, blocking=True, ) - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with( - segment_id=1, - palette="Icefire", - ) + assert method_mock.call_count == 1 + method_mock.assert_called_with(**called_with) + + # Test invalid response, not becoming unavailable + method_mock.side_effect = WLEDError + with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: option}, + blocking=True, + ) + + assert (state := hass.states.get(state.entity_id)) + assert state.state != STATE_UNAVAILABLE + assert method_mock.call_count == 2 + method_mock.assert_called_with(**called_with) + + # Test connection error, leading to becoming unavailable + method_mock.side_effect = WLEDConnectionError + with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: state.entity_id, ATTR_OPTION: option}, + blocking=True, + ) + + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNAVAILABLE + assert method_mock.call_count == 3 + method_mock.assert_called_with(**called_with) @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) @@ -158,86 +160,16 @@ async def test_color_palette_dynamically_handle_segments( assert segment1.state == STATE_UNAVAILABLE -async def test_color_palette_select_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.segment.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Icefire", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgb_light_segment_1_color_palette")) - assert state.state == "Random Cycle" - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") - - -async def test_color_palette_select_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.segment.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_segment_1_color_palette", - ATTR_OPTION: "Icefire", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgb_light_segment_1_color_palette")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.segment.call_count == 1 - mock_wled.segment.assert_called_with(segment_id=1, palette="Icefire") - - async def test_preset_unavailable_without_presets(hass: HomeAssistant) -> None: """Test WLED preset entity is unavailable when presets are not available.""" assert (state := hass.states.get("select.wled_rgb_light_preset")) assert state.state == STATE_UNAVAILABLE -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_preset_state( - hass: HomeAssistant, - mock_wled: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test the creation and values of the WLED selects.""" - assert (state := hass.states.get("select.wled_rgbw_light_preset")) - assert state.attributes.get(ATTR_ICON) == "mdi:playlist-play" - assert state.attributes.get(ATTR_OPTIONS) == ["Preset 1", "Preset 2"] - assert state.state == "Preset 1" - - assert (entry := entity_registry.async_get("select.wled_rgbw_light_preset")) - assert entry.unique_id == "aabbccddee11_preset" - - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_preset", - ATTR_OPTION: "Preset 2", - }, - blocking=True, - ) - assert mock_wled.preset.call_count == 1 - mock_wled.preset.assert_called_with(preset="Preset 2") +async def test_playlist_unavailable_without_playlists(hass: HomeAssistant) -> None: + """Test WLED playlist entity is unavailable when playlists are not available.""" + assert (state := hass.states.get("select.wled_rgb_light_playlist")) + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("device_fixture", ["rgbw"]) @@ -256,92 +188,6 @@ async def test_old_style_preset_active( assert state.state == STATE_UNKNOWN -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_preset_select_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.preset.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_preset", - ATTR_OPTION: "Preset 2", - }, - blocking=True, - ) - await hass.async_block_till_done() - - assert (state := hass.states.get("select.wled_rgbw_light_preset")) - assert state.state == "Preset 1" - assert mock_wled.preset.call_count == 1 - mock_wled.preset.assert_called_with(preset="Preset 2") - - -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_preset_select_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.preset.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_preset", - ATTR_OPTION: "Preset 2", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgbw_light_preset")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.preset.call_count == 1 - mock_wled.preset.assert_called_with(preset="Preset 2") - - -async def test_playlist_unavailable_without_playlists(hass: HomeAssistant) -> None: - """Test WLED playlist entity is unavailable when playlists are not available.""" - assert (state := hass.states.get("select.wled_rgb_light_playlist")) - assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_playlist_state( - hass: HomeAssistant, - mock_wled: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test the creation and values of the WLED selects.""" - - assert (state := hass.states.get("select.wled_rgbw_light_playlist")) - assert state.attributes.get(ATTR_ICON) == "mdi:play-speed" - assert state.attributes.get(ATTR_OPTIONS) == ["Playlist 1", "Playlist 2"] - assert state.state == "Playlist 1" - - assert (entry := entity_registry.async_get("select.wled_rgbw_light_playlist")) - assert entry.unique_id == "aabbccddee11_playlist" - - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_playlist", - ATTR_OPTION: "Playlist 2", - }, - blocking=True, - ) - assert mock_wled.playlist.call_count == 1 - mock_wled.playlist.assert_called_with(playlist="Playlist 2") - - @pytest.mark.parametrize("device_fixture", ["rgbw"]) async def test_old_style_playlist_active( hass: HomeAssistant, @@ -356,128 +202,3 @@ async def test_old_style_playlist_active( assert (state := hass.states.get("select.wled_rgbw_light_playlist")) assert state.state == STATE_UNKNOWN - - -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_playlist_select_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.playlist.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_playlist", - ATTR_OPTION: "Playlist 2", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgbw_light_playlist")) - assert state.state == "Playlist 1" - assert mock_wled.playlist.call_count == 1 - mock_wled.playlist.assert_called_with(playlist="Playlist 2") - - -@pytest.mark.parametrize("device_fixture", ["rgbw"]) -async def test_playlist_select_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.playlist.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgbw_light_playlist", - ATTR_OPTION: "Playlist 2", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgbw_light_playlist")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.playlist.call_count == 1 - mock_wled.playlist.assert_called_with(playlist="Playlist 2") - - -async def test_live_override( - hass: HomeAssistant, - mock_wled: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test the creation and values of the WLED selects.""" - assert (state := hass.states.get("select.wled_rgb_light_live_override")) - assert state.attributes.get(ATTR_ICON) == "mdi:theater" - assert state.attributes.get(ATTR_OPTIONS) == ["0", "1", "2"] - assert state.state == "0" - - assert (entry := entity_registry.async_get("select.wled_rgb_light_live_override")) - assert entry.unique_id == "aabbccddeeff_live_override" - - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_live_override", - ATTR_OPTION: "2", - }, - blocking=True, - ) - assert mock_wled.live.call_count == 1 - mock_wled.live.assert_called_with(live=2) - - -async def test_live_select_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.live.side_effect = WLEDError - - with pytest.raises(HomeAssistantError, match="Invalid response from WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_live_override", - ATTR_OPTION: "1", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgb_light_live_override")) - assert state.state == "0" - assert mock_wled.live.call_count == 1 - mock_wled.live.assert_called_with(live=1) - - -async def test_live_select_connection_error( - hass: HomeAssistant, - mock_wled: MagicMock, -) -> None: - """Test error handling of the WLED selects.""" - mock_wled.live.side_effect = WLEDConnectionError - - with pytest.raises(HomeAssistantError, match="Error communicating with WLED API"): - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.wled_rgb_light_live_override", - ATTR_OPTION: "2", - }, - blocking=True, - ) - - assert (state := hass.states.get("select.wled_rgb_light_live_override")) - assert state.state == STATE_UNAVAILABLE - assert mock_wled.live.call_count == 1 - mock_wled.live.assert_called_with(live=2) From 086bcfb2fcc0b95d6f0e111724557c695bec4030 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 15:06:10 -1000 Subject: [PATCH 0667/1058] Make recorder migration column types for each dialect constants (#90072) Make column types for each dialect constants --- .../components/recorder/migration.py | 89 +++++++++++++------ 1 file changed, 62 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 419ef7aa1173..38eed25bee82 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -25,6 +25,7 @@ from sqlalchemy.schema import AddConstraint, DropConstraint from sqlalchemy.sql.expression import true from homeassistant.core import HomeAssistant +from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes from .const import SupportedDialect @@ -84,6 +85,38 @@ _EMPTY_EVENT_TYPE = "missing_event_type" _LOGGER = logging.getLogger(__name__) +@dataclass +class _ColumnTypesForDialect: + big_int_type: str + timestamp_type: str + context_bin_type: str + + +_MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( + big_int_type="INTEGER(20)", + timestamp_type="DOUBLE PRECISION", + context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})", +) + +_POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect( + big_int_type="INTEGER", + timestamp_type="DOUBLE PRECISION", + context_bin_type="BYTEA", +) + +_SQLITE_COLUMN_TYPES = _ColumnTypesForDialect( + big_int_type="INTEGER", + timestamp_type="FLOAT", + context_bin_type="BLOB", +) + +_COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect] = { + SupportedDialect.MYSQL: _MYSQL_COLUMN_TYPES, + SupportedDialect.POSTGRESQL: _POSTGRESQL_COLUMN_TYPES, + SupportedDialect.SQLITE: _SQLITE_COLUMN_TYPES, +} + + def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) -> None: """Raise if the exception and cause do not contain the match substrs.""" lower_ex_strs = [str(ex).lower(), str(ex.__cause__).lower()] @@ -544,18 +577,9 @@ def _apply_update( # noqa: C901 old_version: int, ) -> None: """Perform operations to bring schema up to date.""" - dialect = engine.dialect.name - big_int = "INTEGER(20)" if dialect == SupportedDialect.MYSQL else "INTEGER" - if dialect == SupportedDialect.MYSQL: - timestamp_type = "DOUBLE PRECISION" - context_bin_type = f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})" - elif dialect == SupportedDialect.POSTGRESQL: - timestamp_type = "DOUBLE PRECISION" - context_bin_type = "BYTEA" - else: - timestamp_type = "FLOAT" - context_bin_type = "BLOB" - + assert engine.dialect.name is not None, "Dialect name must be set" + dialect = try_parse_enum(SupportedDialect, engine.dialect.name) + _column_types = _COLUMN_TYPES_FOR_DIALECT.get(dialect, _SQLITE_COLUMN_TYPES) if new_version == 1: # This used to create ix_events_time_fired, but it was removed in version 32 pass @@ -817,12 +841,14 @@ def _apply_update( # noqa: C901 # of removing any duplicate if they still exist. pass elif new_version == 25: - _add_columns(session_maker, "states", [f"attributes_id {big_int}"]) + _add_columns( + session_maker, "states", [f"attributes_id {_column_types.big_int_type}"] + ) _create_index(session_maker, "states", "ix_states_attributes_id") elif new_version == 26: _create_index(session_maker, "statistics_runs", "ix_statistics_runs_start") elif new_version == 27: - _add_columns(session_maker, "events", [f"data_id {big_int}"]) + _add_columns(session_maker, "events", [f"data_id {_column_types.big_int_type}"]) _create_index(session_maker, "events", "ix_events_data_id") elif new_version == 28: _add_columns(session_maker, "events", ["origin_idx INTEGER"]) @@ -881,11 +907,16 @@ def _apply_update( # noqa: C901 # ALTER TABLE events DROP COLUMN time_fired # ALTER TABLE states DROP COLUMN last_updated # ALTER TABLE states DROP COLUMN last_changed - _add_columns(session_maker, "events", [f"time_fired_ts {timestamp_type}"]) + _add_columns( + session_maker, "events", [f"time_fired_ts {_column_types.timestamp_type}"] + ) _add_columns( session_maker, "states", - [f"last_updated_ts {timestamp_type}", f"last_changed_ts {timestamp_type}"], + [ + f"last_updated_ts {_column_types.timestamp_type}", + f"last_changed_ts {_column_types.timestamp_type}", + ], ) _create_index(session_maker, "events", "ix_events_time_fired_ts") _create_index(session_maker, "events", "ix_events_event_type_time_fired_ts") @@ -917,18 +948,18 @@ def _apply_update( # noqa: C901 session_maker, "statistics", [ - f"created_ts {timestamp_type}", - f"start_ts {timestamp_type}", - f"last_reset_ts {timestamp_type}", + f"created_ts {_column_types.timestamp_type}", + f"start_ts {_column_types.timestamp_type}", + f"last_reset_ts {_column_types.timestamp_type}", ], ) _add_columns( session_maker, "statistics_short_term", [ - f"created_ts {timestamp_type}", - f"start_ts {timestamp_type}", - f"last_reset_ts {timestamp_type}", + f"created_ts {_column_types.timestamp_type}", + f"start_ts {_column_types.timestamp_type}", + f"last_reset_ts {_column_types.timestamp_type}", ], ) _create_index(session_maker, "statistics", "ix_statistics_start_ts") @@ -983,20 +1014,24 @@ def _apply_update( # noqa: C901 session_maker, table, [ - f"context_id_bin {context_bin_type}", - f"context_user_id_bin {context_bin_type}", - f"context_parent_id_bin {context_bin_type}", + f"context_id_bin {_column_types.context_bin_type}", + f"context_user_id_bin {_column_types.context_bin_type}", + f"context_parent_id_bin {_column_types.context_bin_type}", ], ) _create_index(session_maker, "events", "ix_events_context_id_bin") _create_index(session_maker, "states", "ix_states_context_id_bin") elif new_version == 37: - _add_columns(session_maker, "events", [f"event_type_id {big_int}"]) + _add_columns( + session_maker, "events", [f"event_type_id {_column_types.big_int_type}"] + ) _create_index(session_maker, "events", "ix_events_event_type_id") _drop_index(session_maker, "events", "ix_events_event_type_time_fired_ts") _create_index(session_maker, "events", "ix_events_event_type_id_time_fired_ts") elif new_version == 38: - _add_columns(session_maker, "states", [f"metadata_id {big_int}"]) + _add_columns( + session_maker, "states", [f"metadata_id {_column_types.big_int_type}"] + ) _create_index(session_maker, "states", "ix_states_metadata_id") _create_index(session_maker, "states", "ix_states_metadata_id_last_updated_ts") elif new_version == 39: From ddcaa9d3721f2e373c6c6a4f07504598fbc9d339 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 15:08:06 -1000 Subject: [PATCH 0668/1058] Break out statistics repairs into a `auto_repairs` modules (#90068) * Break out statistics schema repairs into a repairs module A future PR will add repairs for events, states, etc * reorg * reorg * reorg * reorg * fixes * fix patch targets * name space rename --- .../recorder/auto_repairs/__init__.py | 1 + .../auto_repairs/statistics/__init__.py | 1 + .../auto_repairs/statistics/duplicates.py | 261 +++++++++ .../auto_repairs/statistics/schema.py | 295 ++++++++++ .../components/recorder/migration.py | 16 +- .../components/recorder/statistics.py | 512 +---------------- .../recorder/auto_repairs/__init__.py | 5 + .../auto_repairs/statistics/__init__.py | 5 + .../statistics/test_duplicates.py | 330 +++++++++++ .../auto_repairs/statistics/test_schema.py | 235 ++++++++ tests/components/recorder/test_statistics.py | 531 +----------------- tests/conftest.py | 10 +- 12 files changed, 1157 insertions(+), 1045 deletions(-) create mode 100644 homeassistant/components/recorder/auto_repairs/__init__.py create mode 100644 homeassistant/components/recorder/auto_repairs/statistics/__init__.py create mode 100644 homeassistant/components/recorder/auto_repairs/statistics/duplicates.py create mode 100644 homeassistant/components/recorder/auto_repairs/statistics/schema.py create mode 100644 tests/components/recorder/auto_repairs/__init__.py create mode 100644 tests/components/recorder/auto_repairs/statistics/__init__.py create mode 100644 tests/components/recorder/auto_repairs/statistics/test_duplicates.py create mode 100644 tests/components/recorder/auto_repairs/statistics/test_schema.py diff --git a/homeassistant/components/recorder/auto_repairs/__init__.py b/homeassistant/components/recorder/auto_repairs/__init__.py new file mode 100644 index 000000000000..aa3880bf1d64 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/__init__.py @@ -0,0 +1 @@ +"""Repairs for Recorder.""" diff --git a/homeassistant/components/recorder/auto_repairs/statistics/__init__.py b/homeassistant/components/recorder/auto_repairs/statistics/__init__.py new file mode 100644 index 000000000000..64bfd4fbb292 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/__init__.py @@ -0,0 +1 @@ +"""Statistics repairs for Recorder.""" diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py new file mode 100644 index 000000000000..8a24dcbf92b8 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -0,0 +1,261 @@ +"""Statistics duplication repairs.""" +from __future__ import annotations + +import json +import logging +import os +from typing import TYPE_CHECKING + +from sqlalchemy import func +from sqlalchemy.engine.row import Row +from sqlalchemy.orm.session import Session +from sqlalchemy.sql.expression import literal_column + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.util import dt as dt_util + +from ...const import SQLITE_MAX_BIND_VARS +from ...db_schema import Statistics, StatisticsBase, StatisticsMeta, StatisticsShortTerm +from ...util import database_job_retry_wrapper, execute + +if TYPE_CHECKING: + from ... import Recorder + +_LOGGER = logging.getLogger(__name__) + + +def _find_duplicates( + session: Session, table: type[StatisticsBase] +) -> tuple[list[int], list[dict]]: + """Find duplicated statistics.""" + subquery = ( + session.query( + table.start, + table.metadata_id, + literal_column("1").label("is_duplicate"), + ) + .group_by(table.metadata_id, table.start) + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + .having(func.count() > 1) + .subquery() + ) + query = ( + session.query( + table.id, + table.metadata_id, + table.created, + table.start, + table.mean, + table.min, + table.max, + table.last_reset, + table.state, + table.sum, + ) + .outerjoin( + subquery, + (subquery.c.metadata_id == table.metadata_id) + & (subquery.c.start == table.start), + ) + .filter(subquery.c.is_duplicate == 1) + .order_by(table.metadata_id, table.start, table.id.desc()) + .limit(1000 * SQLITE_MAX_BIND_VARS) + ) + duplicates = execute(query) + original_as_dict = {} + start = None + metadata_id = None + duplicate_ids: list[int] = [] + non_identical_duplicates_as_dict: list[dict] = [] + + if not duplicates: + return (duplicate_ids, non_identical_duplicates_as_dict) + + def columns_to_dict(duplicate: Row) -> dict: + """Convert a SQLAlchemy row to dict.""" + dict_ = {} + for key in ( + "id", + "metadata_id", + "start", + "created", + "mean", + "min", + "max", + "last_reset", + "state", + "sum", + ): + dict_[key] = getattr(duplicate, key) + return dict_ + + def compare_statistic_rows(row1: dict, row2: dict) -> bool: + """Compare two statistics rows, ignoring id and created.""" + ignore_keys = {"id", "created"} + keys1 = set(row1).difference(ignore_keys) + keys2 = set(row2).difference(ignore_keys) + return keys1 == keys2 and all(row1[k] == row2[k] for k in keys1) + + for duplicate in duplicates: + if start != duplicate.start or metadata_id != duplicate.metadata_id: + original_as_dict = columns_to_dict(duplicate) + start = duplicate.start + metadata_id = duplicate.metadata_id + continue + duplicate_as_dict = columns_to_dict(duplicate) + duplicate_ids.append(duplicate.id) + if not compare_statistic_rows(original_as_dict, duplicate_as_dict): + non_identical_duplicates_as_dict.append( + {"duplicate": duplicate_as_dict, "original": original_as_dict} + ) + + return (duplicate_ids, non_identical_duplicates_as_dict) + + +def _delete_duplicates_from_table( + session: Session, table: type[StatisticsBase] +) -> tuple[int, list[dict]]: + """Identify and delete duplicated statistics from a specified table.""" + all_non_identical_duplicates: list[dict] = [] + total_deleted_rows = 0 + while True: + duplicate_ids, non_identical_duplicates = _find_duplicates(session, table) + if not duplicate_ids: + break + all_non_identical_duplicates.extend(non_identical_duplicates) + for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + deleted_rows = ( + session.query(table) + .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) + .delete(synchronize_session=False) + ) + total_deleted_rows += deleted_rows + return (total_deleted_rows, all_non_identical_duplicates) + + +@database_job_retry_wrapper("delete statistics duplicates", 3) +def delete_statistics_duplicates( + instance: Recorder, hass: HomeAssistant, session: Session +) -> None: + """Identify and delete duplicated statistics. + + A backup will be made of duplicated statistics before it is deleted. + """ + deleted_statistics_rows, non_identical_duplicates = _delete_duplicates_from_table( + session, Statistics + ) + if deleted_statistics_rows: + _LOGGER.info("Deleted %s duplicated statistics rows", deleted_statistics_rows) + + if non_identical_duplicates: + isotime = dt_util.utcnow().isoformat() + backup_file_name = f"deleted_statistics.{isotime}.json" + backup_path = hass.config.path(STORAGE_DIR, backup_file_name) + + os.makedirs(os.path.dirname(backup_path), exist_ok=True) + with open(backup_path, "w", encoding="utf8") as backup_file: + json.dump( + non_identical_duplicates, + backup_file, + indent=4, + sort_keys=True, + cls=JSONEncoder, + ) + _LOGGER.warning( + ( + "Deleted %s non identical duplicated %s rows, a backup of the deleted" + " rows has been saved to %s" + ), + len(non_identical_duplicates), + Statistics.__tablename__, + backup_path, + ) + + deleted_short_term_statistics_rows, _ = _delete_duplicates_from_table( + session, StatisticsShortTerm + ) + if deleted_short_term_statistics_rows: + _LOGGER.warning( + "Deleted duplicated short term statistic rows, please report at %s", + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", + ) + + +def _find_statistics_meta_duplicates(session: Session) -> list[int]: + """Find duplicated statistics_meta.""" + # When querying the database, be careful to only explicitly query for columns + # which were present in schema version 29. If querying the table, SQLAlchemy + # will refer to future columns. + subquery = ( + session.query( + StatisticsMeta.statistic_id, + literal_column("1").label("is_duplicate"), + ) + .group_by(StatisticsMeta.statistic_id) + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + .having(func.count() > 1) + .subquery() + ) + query = ( + session.query(StatisticsMeta.statistic_id, StatisticsMeta.id) + .outerjoin( + subquery, + (subquery.c.statistic_id == StatisticsMeta.statistic_id), + ) + .filter(subquery.c.is_duplicate == 1) + .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) + .limit(1000 * SQLITE_MAX_BIND_VARS) + ) + duplicates = execute(query) + statistic_id = None + duplicate_ids: list[int] = [] + + if not duplicates: + return duplicate_ids + + for duplicate in duplicates: + if statistic_id != duplicate.statistic_id: + statistic_id = duplicate.statistic_id + continue + duplicate_ids.append(duplicate.id) + + return duplicate_ids + + +def _delete_statistics_meta_duplicates(session: Session) -> int: + """Identify and delete duplicated statistics from a specified table.""" + total_deleted_rows = 0 + while True: + duplicate_ids = _find_statistics_meta_duplicates(session) + if not duplicate_ids: + break + for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): + deleted_rows = ( + session.query(StatisticsMeta) + .filter( + StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) + ) + .delete(synchronize_session=False) + ) + total_deleted_rows += deleted_rows + return total_deleted_rows + + +@database_job_retry_wrapper("delete statistics meta duplicates", 3) +def delete_statistics_meta_duplicates(instance: Recorder, session: Session) -> None: + """Identify and delete duplicated statistics_meta. + + This is used when migrating from schema version 28 to schema version 29. + """ + deleted_statistics_rows = _delete_statistics_meta_duplicates(session) + if deleted_statistics_rows: + statistics_meta_manager = instance.statistics_meta_manager + statistics_meta_manager.reset() + statistics_meta_manager.load(session) + _LOGGER.info( + "Deleted %s duplicated statistics_meta rows", deleted_statistics_rows + ) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py new file mode 100644 index 000000000000..bbf59080ac19 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -0,0 +1,295 @@ +"""Statistics schema repairs.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +import contextlib +from datetime import datetime +import logging +from typing import TYPE_CHECKING + +from sqlalchemy import text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import OperationalError, SQLAlchemyError +from sqlalchemy.orm.session import Session + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from ...const import DOMAIN, SupportedDialect +from ...db_schema import Statistics, StatisticsShortTerm +from ...models import StatisticData, StatisticMetaData, datetime_to_timestamp_or_none +from ...statistics import ( + _import_statistics_with_session, + _statistics_during_period_with_session, +) +from ...util import session_scope + +if TYPE_CHECKING: + from ... import Recorder + +_LOGGER = logging.getLogger(__name__) + + +def _validate_db_schema_utf8( + instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + # This name can't be represented unless 4-byte UTF-8 unicode is supported + utf8_name = "𓆚𓃗" + statistic_id = f"{DOMAIN}.db_test" + + metadata: StatisticMetaData = { + "has_mean": True, + "has_sum": True, + "name": utf8_name, + "source": DOMAIN, + "statistic_id": statistic_id, + "unit_of_measurement": None, + } + statistics_meta_manager = instance.statistics_meta_manager + + # Try inserting some metadata which needs utf8mb4 support + try: + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=session_maker(), read_only=True) as session: + old_metadata_dict = statistics_meta_manager.get_many( + session, statistic_ids={statistic_id} + ) + try: + statistics_meta_manager.update_or_add( + session, metadata, old_metadata_dict + ) + statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) + except OperationalError as err: + if err.orig and err.orig.args[0] == 1366: + _LOGGER.debug( + "Database table statistics_meta does not support 4-byte UTF-8" + ) + schema_errors.add("statistics_meta.4-byte UTF-8") + session.rollback() + else: + raise + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + return schema_errors + + +def _get_future_year() -> int: + """Get a year in the future.""" + return datetime.now().year + 1 + + +def _validate_db_schema( + hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + statistics_meta_manager = instance.statistics_meta_manager + + # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL + if instance.dialect_name not in ( + SupportedDialect.MYSQL, + SupportedDialect.POSTGRESQL, + ): + return schema_errors + + # This number can't be accurately represented as a 32-bit float + precise_number = 1.000000000000001 + # This time can't be accurately represented unless datetimes have µs precision + # + # We want to insert statistics for a time in the future, in case they + # have conflicting metadata_id's with existing statistics that were + # never cleaned up. By inserting in the future, we can be sure that + # that by selecting the last inserted row, we will get the one we + # just inserted. + # + future_year = _get_future_year() + precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) + statistic_id = f"{DOMAIN}.db_test" + + metadata: StatisticMetaData = { + "has_mean": True, + "has_sum": True, + "name": None, + "source": DOMAIN, + "statistic_id": statistic_id, + "unit_of_measurement": None, + } + statistics: StatisticData = { + "last_reset": precise_time, + "max": precise_number, + "mean": precise_number, + "min": precise_number, + "start": precise_time, + "state": precise_number, + "sum": precise_number, + } + + def check_columns( + schema_errors: set[str], + stored: Mapping, + expected: Mapping, + columns: tuple[str, ...], + table_name: str, + supports: str, + ) -> None: + for column in columns: + if stored[column] != expected[column]: + schema_errors.add(f"{table_name}.{supports}") + _LOGGER.error( + "Column %s in database table %s does not support %s (stored=%s != expected=%s)", + column, + table_name, + supports, + stored[column], + expected[column], + ) + + # Insert / adjust a test statistics row in each of the tables + tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( + Statistics, + StatisticsShortTerm, + ) + try: + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=session_maker(), read_only=True) as session: + for table in tables: + _import_statistics_with_session( + instance, session, metadata, (statistics,), table + ) + stored_statistics = _statistics_during_period_with_session( + hass, + session, + start_time, + None, + {statistic_id}, + "hour" if table == Statistics else "5minute", + None, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + if not (stored_statistic := stored_statistics.get(statistic_id)): + _LOGGER.warning( + "Schema validation failed for table: %s", table.__tablename__ + ) + continue + + # We want to look at the last inserted row to make sure there + # is not previous garbage data in the table that would cause + # the test to produce an incorrect result. To achieve this, + # we inserted a row in the future, and now we select the last + # inserted row back. + last_stored_statistic = stored_statistic[-1] + check_columns( + schema_errors, + last_stored_statistic, + statistics, + ("max", "mean", "min", "state", "sum"), + table.__tablename__, + "double precision", + ) + assert statistics["last_reset"] + check_columns( + schema_errors, + last_stored_statistic, + { + "last_reset": datetime_to_timestamp_or_none( + statistics["last_reset"] + ), + "start": datetime_to_timestamp_or_none(statistics["start"]), + }, + ("start", "last_reset"), + table.__tablename__, + "µs precision", + ) + statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + return schema_errors + + +def validate_db_schema( + hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + schema_errors |= _validate_db_schema_utf8(instance, session_maker) + schema_errors |= _validate_db_schema(hass, instance, session_maker) + if schema_errors: + _LOGGER.debug( + "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) + ) + return schema_errors + + +def correct_db_schema( + instance: Recorder, + engine: Engine, + session_maker: Callable[[], Session], + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + from ...migration import _modify_columns # pylint: disable=import-outside-toplevel + + if "statistics_meta.4-byte UTF-8" in schema_errors: + # Attempt to convert the table to utf8mb4 + _LOGGER.warning( + ( + "Updating character set and collation of table %s to utf8mb4. " + "Note: this can take several minutes on large databases and slow " + "computers. Please be patient!" + ), + "statistics_meta", + ) + with contextlib.suppress(SQLAlchemyError), session_scope( + session=session_maker() + ) as session: + connection = session.connection() + connection.execute( + # Using LOCK=EXCLUSIVE to prevent the database from corrupting + # https://github.com/home-assistant/core/issues/56104 + text( + "ALTER TABLE statistics_meta CONVERT TO CHARACTER SET utf8mb4" + " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" + ) + ) + + tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( + Statistics, + StatisticsShortTerm, + ) + for table in tables: + if f"{table.__tablename__}.double precision" in schema_errors: + # Attempt to convert float columns to double precision + _modify_columns( + session_maker, + engine, + table.__tablename__, + [ + "mean DOUBLE PRECISION", + "min DOUBLE PRECISION", + "max DOUBLE PRECISION", + "state DOUBLE PRECISION", + "sum DOUBLE PRECISION", + ], + ) + if f"{table.__tablename__}.µs precision" in schema_errors: + # Attempt to convert timestamp columns to µs precision + _modify_columns( + session_maker, + engine, + table.__tablename__, + [ + "last_reset_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", + ], + ) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 38eed25bee82..6fc2138d918f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -28,6 +28,14 @@ from homeassistant.core import HomeAssistant from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes +from .auto_repairs.statistics.duplicates import ( + delete_statistics_duplicates, + delete_statistics_meta_duplicates, +) +from .auto_repairs.statistics.schema import ( + correct_db_schema as statistics_correct_db_schema, + validate_db_schema as statistics_validate_db_schema, +) from .const import SupportedDialect from .db_schema import ( CONTEXT_ID_BIN_MAX_LENGTH, @@ -55,13 +63,7 @@ from .queries import ( find_states_context_ids_to_migrate, has_used_states_event_ids, ) -from .statistics import ( - correct_db_schema as statistics_correct_db_schema, - delete_statistics_duplicates, - delete_statistics_meta_duplicates, - get_start_time, - validate_db_schema as statistics_validate_db_schema, -) +from .statistics import get_start_time from .tasks import ( CommitTask, PostSchemaMigrationTask, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 34adcbddcc65..82fbf7798f97 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2,34 +2,28 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Sequence import contextlib import dataclasses from datetime import datetime, timedelta from functools import lru_cache, partial from itertools import chain, groupby -import json import logging from operator import itemgetter -import os 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 -from sqlalchemy.engine import Engine from sqlalchemy.engine.row import Row -from sqlalchemy.exc import OperationalError, SQLAlchemyError, StatementError +from sqlalchemy.exc import SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session -from sqlalchemy.sql.expression import literal_column from sqlalchemy.sql.lambdas import StatementLambdaElement import voluptuous as vol from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.json import JSONEncoder -from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( @@ -53,14 +47,12 @@ from .const import ( DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, - SQLITE_MAX_BIND_VARS, SupportedDialect, ) from .db_schema import ( STATISTICS_TABLES, Statistics, StatisticsBase, - StatisticsMeta, StatisticsRuns, StatisticsShortTerm, ) @@ -73,7 +65,6 @@ from .models import ( process_timestamp, ) from .util import ( - database_job_retry_wrapper, execute, execute_stmt_lambda_element, get_instance, @@ -333,240 +324,6 @@ def get_start_time() -> datetime: return last_period -def _find_duplicates( - session: Session, table: type[StatisticsBase] -) -> tuple[list[int], list[dict]]: - """Find duplicated statistics.""" - subquery = ( - session.query( - table.start, - table.metadata_id, - literal_column("1").label("is_duplicate"), - ) - .group_by(table.metadata_id, table.start) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - .having(func.count() > 1) - .subquery() - ) - query = ( - session.query( - table.id, - table.metadata_id, - table.created, - table.start, - table.mean, - table.min, - table.max, - table.last_reset, - table.state, - table.sum, - ) - .outerjoin( - subquery, - (subquery.c.metadata_id == table.metadata_id) - & (subquery.c.start == table.start), - ) - .filter(subquery.c.is_duplicate == 1) - .order_by(table.metadata_id, table.start, table.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) - ) - duplicates = execute(query) - original_as_dict = {} - start = None - metadata_id = None - duplicate_ids: list[int] = [] - non_identical_duplicates_as_dict: list[dict] = [] - - if not duplicates: - return (duplicate_ids, non_identical_duplicates_as_dict) - - def columns_to_dict(duplicate: Row) -> dict: - """Convert a SQLAlchemy row to dict.""" - dict_ = {} - for key in ( - "id", - "metadata_id", - "start", - "created", - "mean", - "min", - "max", - "last_reset", - "state", - "sum", - ): - dict_[key] = getattr(duplicate, key) - return dict_ - - def compare_statistic_rows(row1: dict, row2: dict) -> bool: - """Compare two statistics rows, ignoring id and created.""" - ignore_keys = {"id", "created"} - keys1 = set(row1).difference(ignore_keys) - keys2 = set(row2).difference(ignore_keys) - return keys1 == keys2 and all(row1[k] == row2[k] for k in keys1) - - for duplicate in duplicates: - if start != duplicate.start or metadata_id != duplicate.metadata_id: - original_as_dict = columns_to_dict(duplicate) - start = duplicate.start - metadata_id = duplicate.metadata_id - continue - duplicate_as_dict = columns_to_dict(duplicate) - duplicate_ids.append(duplicate.id) - if not compare_statistic_rows(original_as_dict, duplicate_as_dict): - non_identical_duplicates_as_dict.append( - {"duplicate": duplicate_as_dict, "original": original_as_dict} - ) - - return (duplicate_ids, non_identical_duplicates_as_dict) - - -def _delete_duplicates_from_table( - session: Session, table: type[StatisticsBase] -) -> tuple[int, list[dict]]: - """Identify and delete duplicated statistics from a specified table.""" - all_non_identical_duplicates: list[dict] = [] - total_deleted_rows = 0 - while True: - duplicate_ids, non_identical_duplicates = _find_duplicates(session, table) - if not duplicate_ids: - break - all_non_identical_duplicates.extend(non_identical_duplicates) - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): - deleted_rows = ( - session.query(table) - .filter(table.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS])) - .delete(synchronize_session=False) - ) - total_deleted_rows += deleted_rows - return (total_deleted_rows, all_non_identical_duplicates) - - -@database_job_retry_wrapper("delete statistics duplicates", 3) -def delete_statistics_duplicates( - instance: Recorder, hass: HomeAssistant, session: Session -) -> None: - """Identify and delete duplicated statistics. - - A backup will be made of duplicated statistics before it is deleted. - """ - deleted_statistics_rows, non_identical_duplicates = _delete_duplicates_from_table( - session, Statistics - ) - if deleted_statistics_rows: - _LOGGER.info("Deleted %s duplicated statistics rows", deleted_statistics_rows) - - if non_identical_duplicates: - isotime = dt_util.utcnow().isoformat() - backup_file_name = f"deleted_statistics.{isotime}.json" - backup_path = hass.config.path(STORAGE_DIR, backup_file_name) - - os.makedirs(os.path.dirname(backup_path), exist_ok=True) - with open(backup_path, "w", encoding="utf8") as backup_file: - json.dump( - non_identical_duplicates, - backup_file, - indent=4, - sort_keys=True, - cls=JSONEncoder, - ) - _LOGGER.warning( - ( - "Deleted %s non identical duplicated %s rows, a backup of the deleted" - " rows has been saved to %s" - ), - len(non_identical_duplicates), - Statistics.__tablename__, - backup_path, - ) - - deleted_short_term_statistics_rows, _ = _delete_duplicates_from_table( - session, StatisticsShortTerm - ) - if deleted_short_term_statistics_rows: - _LOGGER.warning( - "Deleted duplicated short term statistic rows, please report at %s", - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+recorder%22", - ) - - -def _find_statistics_meta_duplicates(session: Session) -> list[int]: - """Find duplicated statistics_meta.""" - # When querying the database, be careful to only explicitly query for columns - # which were present in schema version 29. If querying the table, SQLAlchemy - # will refer to future columns. - subquery = ( - session.query( - StatisticsMeta.statistic_id, - literal_column("1").label("is_duplicate"), - ) - .group_by(StatisticsMeta.statistic_id) - # https://github.com/sqlalchemy/sqlalchemy/issues/9189 - # pylint: disable-next=not-callable - .having(func.count() > 1) - .subquery() - ) - query = ( - session.query(StatisticsMeta.statistic_id, StatisticsMeta.id) - .outerjoin( - subquery, - (subquery.c.statistic_id == StatisticsMeta.statistic_id), - ) - .filter(subquery.c.is_duplicate == 1) - .order_by(StatisticsMeta.statistic_id, StatisticsMeta.id.desc()) - .limit(1000 * SQLITE_MAX_BIND_VARS) - ) - duplicates = execute(query) - statistic_id = None - duplicate_ids: list[int] = [] - - if not duplicates: - return duplicate_ids - - for duplicate in duplicates: - if statistic_id != duplicate.statistic_id: - statistic_id = duplicate.statistic_id - continue - duplicate_ids.append(duplicate.id) - - return duplicate_ids - - -def _delete_statistics_meta_duplicates(session: Session) -> int: - """Identify and delete duplicated statistics from a specified table.""" - total_deleted_rows = 0 - while True: - duplicate_ids = _find_statistics_meta_duplicates(session) - if not duplicate_ids: - break - for i in range(0, len(duplicate_ids), SQLITE_MAX_BIND_VARS): - deleted_rows = ( - session.query(StatisticsMeta) - .filter( - StatisticsMeta.id.in_(duplicate_ids[i : i + SQLITE_MAX_BIND_VARS]) - ) - .delete(synchronize_session=False) - ) - total_deleted_rows += deleted_rows - return total_deleted_rows - - -def delete_statistics_meta_duplicates(instance: Recorder, session: Session) -> None: - """Identify and delete duplicated statistics_meta. - - This is used when migrating from schema version 28 to schema version 29. - """ - deleted_statistics_rows = _delete_statistics_meta_duplicates(session) - if deleted_statistics_rows: - statistics_meta_manager = instance.statistics_meta_manager - statistics_meta_manager.reset() - statistics_meta_manager.load(session) - _LOGGER.info( - "Deleted %s duplicated statistics_meta rows", deleted_statistics_rows - ) - - def _compile_hourly_statistics_summary_mean_stmt( start_time_ts: float, end_time_ts: float ) -> StatementLambdaElement: @@ -2478,271 +2235,6 @@ def async_change_statistics_unit( ) -def _validate_db_schema_utf8( - instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - - # Lack of full utf8 support is only an issue for MySQL / MariaDB - if instance.dialect_name != SupportedDialect.MYSQL: - return schema_errors - - # This name can't be represented unless 4-byte UTF-8 unicode is supported - utf8_name = "𓆚𓃗" - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": utf8_name, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics_meta_manager = instance.statistics_meta_manager - - # Try inserting some metadata which needs utfmb4 support - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids={statistic_id} - ) - try: - statistics_meta_manager.update_or_add( - session, metadata, old_metadata_dict - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except OperationalError as err: - if err.orig and err.orig.args[0] == 1366: - _LOGGER.debug( - "Database table statistics_meta does not support 4-byte UTF-8" - ) - schema_errors.add("statistics_meta.4-byte UTF-8") - session.rollback() - else: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - return schema_errors - - -def _get_future_year() -> int: - """Get a year in the future.""" - return datetime.now().year + 1 - - -def _validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - statistics_meta_manager = instance.statistics_meta_manager - - # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL - if instance.dialect_name not in ( - SupportedDialect.MYSQL, - SupportedDialect.POSTGRESQL, - ): - return schema_errors - - # This number can't be accurately represented as a 32-bit float - precise_number = 1.000000000000001 - # This time can't be accurately represented unless datetimes have µs precision - # - # We want to insert statistics for a time in the future, in case they - # have conflicting metadata_id's with existing statistics that were - # never cleaned up. By inserting in the future, we can be sure that - # that by selecting the last inserted row, we will get the one we - # just inserted. - # - future_year = _get_future_year() - precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": None, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics: StatisticData = { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - - def check_columns( - schema_errors: set[str], - stored: Mapping, - expected: Mapping, - columns: tuple[str, ...], - table_name: str, - supports: str, - ) -> None: - for column in columns: - if stored[column] != expected[column]: - schema_errors.add(f"{table_name}.{supports}") - _LOGGER.error( - "Column %s in database table %s does not support %s (stored=%s != expected=%s)", - column, - table_name, - supports, - stored[column], - expected[column], - ) - - # Insert / adjust a test statistics row in each of the tables - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, - ) - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - for table in tables: - _import_statistics_with_session( - instance, session, metadata, (statistics,), table - ) - stored_statistics = _statistics_during_period_with_session( - hass, - session, - start_time, - None, - {statistic_id}, - "hour" if table == Statistics else "5minute", - None, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - if not (stored_statistic := stored_statistics.get(statistic_id)): - _LOGGER.warning( - "Schema validation failed for table: %s", table.__tablename__ - ) - continue - - # We want to look at the last inserted row to make sure there - # is not previous garbage data in the table that would cause - # the test to produce an incorrect result. To achieve this, - # we inserted a row in the future, and now we select the last - # inserted row back. - last_stored_statistic = stored_statistic[-1] - check_columns( - schema_errors, - last_stored_statistic, - statistics, - ("max", "mean", "min", "state", "sum"), - table.__tablename__, - "double precision", - ) - assert statistics["last_reset"] - check_columns( - schema_errors, - last_stored_statistic, - { - "last_reset": datetime_to_timestamp_or_none( - statistics["last_reset"] - ), - "start": datetime_to_timestamp_or_none(statistics["start"]), - }, - ("start", "last_reset"), - table.__tablename__, - "µs precision", - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - - return schema_errors - - -def validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - schema_errors |= _validate_db_schema_utf8(instance, session_maker) - schema_errors |= _validate_db_schema(hass, instance, session_maker) - if schema_errors: - _LOGGER.debug( - "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) - ) - return schema_errors - - -def correct_db_schema( - instance: Recorder, - engine: Engine, - session_maker: Callable[[], Session], - schema_errors: set[str], -) -> None: - """Correct issues detected by validate_db_schema.""" - from .migration import _modify_columns # pylint: disable=import-outside-toplevel - - if "statistics_meta.4-byte UTF-8" in schema_errors: - # Attempt to convert the table to utf8mb4 - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4. " - "Note: this can take several minutes on large databases and slow " - "computers. Please be patient!" - ), - "statistics_meta", - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - "ALTER TABLE statistics_meta CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) - - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, - ) - for table in tables: - if f"{table.__tablename__}.double precision" in schema_errors: - # Attempt to convert float columns to double precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", - ], - ) - if f"{table.__tablename__}.µs precision" in schema_errors: - # Attempt to convert timestamp columns to µs precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ) - - def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: """Clean up the statistics migration from timestamp to datetime. diff --git a/tests/components/recorder/auto_repairs/__init__.py b/tests/components/recorder/auto_repairs/__init__.py new file mode 100644 index 000000000000..6e98d881ea9c --- /dev/null +++ b/tests/components/recorder/auto_repairs/__init__.py @@ -0,0 +1,5 @@ +"""Tests for Recorder component.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/statistics/__init__.py b/tests/components/recorder/auto_repairs/statistics/__init__.py new file mode 100644 index 000000000000..6e98d881ea9c --- /dev/null +++ b/tests/components/recorder/auto_repairs/statistics/__init__.py @@ -0,0 +1,5 @@ +"""Tests for Recorder component.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/statistics/test_duplicates.py b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py new file mode 100644 index 000000000000..53dac0b6ab2e --- /dev/null +++ b/tests/components/recorder/auto_repairs/statistics/test_duplicates.py @@ -0,0 +1,330 @@ +"""Test removing statistics duplicates.""" +from collections.abc import Callable + +# pylint: disable=invalid-name +import importlib +import sys +from unittest.mock import patch + +import py +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from homeassistant.components import recorder +from homeassistant.components.recorder import statistics +from homeassistant.components.recorder.auto_repairs.statistics.duplicates import ( + delete_statistics_duplicates, + delete_statistics_meta_duplicates, +) +from homeassistant.components.recorder.const import SQLITE_URL_PREFIX +from homeassistant.components.recorder.statistics import async_add_external_statistics +from homeassistant.components.recorder.util import session_scope +from homeassistant.core import HomeAssistant +from homeassistant.helpers import recorder as recorder_helper +from homeassistant.setup import setup_component +import homeassistant.util.dt as dt_util + +from ...common import wait_recording_done + +from tests.common import get_test_home_assistant + +ORIG_TZ = dt_util.DEFAULT_TIME_ZONE + + +def test_delete_duplicates_no_duplicates( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test removal of duplicated statistics.""" + hass = hass_recorder() + wait_recording_done(hass) + instance = recorder.get_instance(hass) + with session_scope(hass=hass) as session: + delete_statistics_duplicates(instance, hass, session) + assert "duplicated statistics rows" not in caplog.text + assert "Found non identical" not in caplog.text + assert "Found duplicated" not in caplog.text + + +def test_duplicate_statistics_handle_integrity_error( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test the recorder does not blow up if statistics is duplicated.""" + hass = hass_recorder() + wait_recording_done(hass) + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_statistics_1 = [ + { + "start": period1, + "last_reset": None, + "state": 3, + "sum": 5, + }, + ] + external_energy_statistics_2 = [ + { + "start": period2, + "last_reset": None, + "state": 3, + "sum": 6, + } + ] + + with patch.object( + statistics, "_statistics_exists", return_value=False + ), patch.object( + statistics, "_insert_statistics", wraps=statistics._insert_statistics + ) as insert_statistics_mock: + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_2 + ) + wait_recording_done(hass) + assert insert_statistics_mock.call_count == 3 + + with session_scope(hass=hass) as session: + tmp = session.query(recorder.db_schema.Statistics).all() + assert len(tmp) == 2 + + assert "Blocked attempt to insert duplicated statistic rows" in caplog.text + + +def _create_engine_28(*args, **kwargs): + """Test version of create_engine that initializes with old schema. + + This simulates an existing db with the old schema. + """ + module = "tests.components.recorder.db_schema_28" + importlib.import_module(module) + old_db_schema = sys.modules[module] + engine = create_engine(*args, **kwargs) + old_db_schema.Base.metadata.create_all(engine) + with Session(engine) as session: + session.add( + recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) + ) + session.add( + recorder.db_schema.SchemaChanges( + schema_version=old_db_schema.SCHEMA_VERSION + ) + ) + session.commit() + return engine + + +def test_delete_metadata_duplicates( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: + """Test removal of duplicated statistics.""" + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + module = "tests.components.recorder.db_schema_28" + importlib.import_module(module) + old_db_schema = sys.modules[module] + + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_co2_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Fossil percentage", + "source": "test", + "statistic_id": "test:fossil_percentage", + "unit_of_measurement": "%", + } + + # Create some duplicated statistics_meta with schema version 28 + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION + ), patch( + "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 + ): + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) + ) + + with session_scope(hass=hass) as session: + tmp = session.query(recorder.db_schema.StatisticsMeta).all() + assert len(tmp) == 3 + assert tmp[0].id == 1 + assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[1].id == 2 + assert tmp[1].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[2].id == 3 + assert tmp[2].statistic_id == "test:fossil_percentage" + + hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + + # Test that the duplicates are removed during migration from schema 28 + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + + assert "Deleted 1 duplicated statistics_meta rows" in caplog.text + with session_scope(hass=hass) as session: + tmp = session.query(recorder.db_schema.StatisticsMeta).all() + assert len(tmp) == 2 + assert tmp[0].id == 2 + assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[1].id == 3 + assert tmp[1].statistic_id == "test:fossil_percentage" + + hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + + +def test_delete_metadata_duplicates_many( + caplog: pytest.LogCaptureFixture, tmpdir: py.path.local +) -> None: + """Test removal of duplicated statistics.""" + test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") + dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" + + module = "tests.components.recorder.db_schema_28" + importlib.import_module(module) + old_db_schema = sys.modules[module] + + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_1", + "unit_of_measurement": "kWh", + } + external_energy_metadata_2 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import_tariff_2", + "unit_of_measurement": "kWh", + } + external_co2_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Fossil percentage", + "source": "test", + "statistic_id": "test:fossil_percentage", + "unit_of_measurement": "%", + } + + # Create some duplicated statistics with schema version 28 + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION + ), patch( + "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 + ): + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + wait_recording_done(hass) + wait_recording_done(hass) + + with session_scope(hass=hass) as session: + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) + ) + for _ in range(1100): + session.add( + recorder.db_schema.StatisticsMeta.from_meta( + external_energy_metadata_1 + ) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) + ) + + hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + + # Test that the duplicates are removed during migration from schema 28 + hass = get_test_home_assistant() + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + + assert "Deleted 1102 duplicated statistics_meta rows" in caplog.text + with session_scope(hass=hass) as session: + tmp = session.query(recorder.db_schema.StatisticsMeta).all() + assert len(tmp) == 3 + assert tmp[0].id == 1101 + assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" + assert tmp[1].id == 1103 + assert tmp[1].statistic_id == "test:total_energy_import_tariff_2" + assert tmp[2].id == 1105 + assert tmp[2].statistic_id == "test:fossil_percentage" + + hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ + + +def test_delete_metadata_duplicates_no_duplicates( + hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture +) -> None: + """Test removal of duplicated statistics.""" + hass = hass_recorder() + wait_recording_done(hass) + with session_scope(hass=hass) as session: + instance = recorder.get_instance(hass) + delete_statistics_meta_duplicates(instance, session) + assert "duplicated statistics_meta rows" not in caplog.text diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py new file mode 100644 index 000000000000..2c4e06580f5f --- /dev/null +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -0,0 +1,235 @@ +"""The test repairing statistics schema.""" + +# pylint: disable=invalid-name +from datetime import datetime +from unittest.mock import ANY, DEFAULT, MagicMock, patch + +import pytest +from sqlalchemy.exc import OperationalError + +from homeassistant.components.recorder.auto_repairs.statistics.schema import ( + _get_future_year, +) +from homeassistant.components.recorder.statistics import ( + _statistics_during_period_with_session, +) +from homeassistant.components.recorder.table_managers.statistics_meta import ( + StatisticsMetaManager, +) +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from ...common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with MySQL and PostgreSQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + assert "Schema validation failed" not in caplog.text + assert "Detected statistics schema errors" not in caplog.text + assert "Database is about to correct DB schema errors" not in caplog.text + + +@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + orig_error = MagicMock() + orig_error.args = [1366] + utf8_error = OperationalError("", "", orig=orig_error) + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.table_managers.statistics_meta.StatisticsMetaManager.update_or_add", + wraps=StatisticsMetaManager.update_or_add, + side_effect=[utf8_error, DEFAULT, DEFAULT], + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: statistics_meta.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table statistics_meta to utf8mb4" + in caplog.text + ) + + +@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +@pytest.mark.parametrize( + ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) +) +@pytest.mark.parametrize( + ("column", "value"), + (("max", 1.0), ("mean", 1.0), ("min", 1.0), ("state", 1.0), ("sum", 1.0)), +) +async def test_validate_db_schema_fix_float_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, + table, + replace_index, + column, + value, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + orig_error = MagicMock() + orig_error.args = [1366] + precise_number = 1.000000000000001 + fixed_future_year = _get_future_year() + precise_time = datetime(fixed_future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + statistics = { + "recorder.db_test": [ + { + "last_reset": precise_time.timestamp(), + "max": precise_number, + "mean": precise_number, + "min": precise_number, + "start": precise_time.timestamp(), + "state": precise_number, + "sum": precise_number, + } + ] + } + statistics["recorder.db_test"][0][column] = value + fake_statistics = [DEFAULT, DEFAULT] + fake_statistics[replace_index] = statistics + + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.statistics.schema._get_future_year", + return_value=fixed_future_year, + ), patch( + "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", + side_effect=fake_statistics, + wraps=_statistics_during_period_with_session, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + f"Database is about to correct DB schema errors: {table}.double precision" + in caplog.text + ) + modification = [ + "mean DOUBLE PRECISION", + "min DOUBLE PRECISION", + "max DOUBLE PRECISION", + "state DOUBLE PRECISION", + "sum DOUBLE PRECISION", + ] + modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) + + +@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize( + ("db_engine", "modification"), + ( + ("mysql", ["last_reset_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION"]), + ( + "postgresql", + [ + "last_reset_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", + ], + ), + ), +) +@pytest.mark.parametrize( + ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) +) +@pytest.mark.parametrize( + ("column", "value"), + ( + ("last_reset", "2020-10-06T00:00:00+00:00"), + ("start", "2020-10-06T00:00:00+00:00"), + ), +) +async def test_validate_db_schema_fix_statistics_datetime_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, + modification, + table, + replace_index, + column, + value, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + orig_error = MagicMock() + orig_error.args = [1366] + precise_number = 1.000000000000001 + precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) + statistics = { + "recorder.db_test": [ + { + "last_reset": precise_time, + "max": precise_number, + "mean": precise_number, + "min": precise_number, + "start": precise_time, + "state": precise_number, + "sum": precise_number, + } + ] + } + statistics["recorder.db_test"][0][column] = value + fake_statistics = [DEFAULT, DEFAULT] + fake_statistics[replace_index] = statistics + + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", + side_effect=fake_statistics, + wraps=_statistics_during_period_with_session, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + f"Database is about to correct DB schema errors: {table}.µs precision" + in caplog.text + ) + modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index d783d72be2da..ebad039ca454 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -2,20 +2,14 @@ from collections.abc import Callable # pylint: disable=invalid-name -from datetime import datetime, timedelta -import importlib -import sys -from unittest.mock import ANY, DEFAULT, MagicMock, patch +from datetime import timedelta +from unittest.mock import patch -import py import pytest -from sqlalchemy import create_engine, select -from sqlalchemy.exc import OperationalError -from sqlalchemy.orm import Session +from sqlalchemy import select from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, history, statistics -from homeassistant.components.recorder.const import SQLITE_URL_PREFIX from homeassistant.components.recorder.db_schema import StatisticsShortTerm from homeassistant.components.recorder.models import ( datetime_to_timestamp_or_none, @@ -26,12 +20,8 @@ from homeassistant.components.recorder.statistics import ( _generate_max_mean_min_statistic_in_sub_period_stmt, _generate_statistics_at_time_stmt, _generate_statistics_during_period_stmt, - _get_future_year, - _statistics_during_period_with_session, async_add_external_statistics, async_import_statistics, - delete_statistics_duplicates, - delete_statistics_meta_duplicates, get_last_short_term_statistics, get_last_statistics, get_latest_short_term_statistics, @@ -39,14 +29,12 @@ from homeassistant.components.recorder.statistics import ( list_statistic_ids, ) from homeassistant.components.recorder.table_managers.statistics_meta import ( - StatisticsMetaManager, _generate_get_metadata_stmt, ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import UNIT_CONVERTERS from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util @@ -59,8 +47,8 @@ from .common import ( wait_recording_done, ) -from tests.common import get_test_home_assistant, mock_registry -from tests.typing import RecorderInstanceGenerator, WebSocketGenerator +from tests.common import mock_registry +from tests.typing import WebSocketGenerator ORIG_TZ = dt_util.DEFAULT_TIME_ZONE @@ -1254,515 +1242,6 @@ def test_monthly_statistics( dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) -def test_delete_duplicates_no_duplicates( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture -) -> None: - """Test removal of duplicated statistics.""" - hass = hass_recorder() - wait_recording_done(hass) - instance = recorder.get_instance(hass) - with session_scope(hass=hass) as session: - delete_statistics_duplicates(instance, hass, session) - assert "duplicated statistics rows" not in caplog.text - assert "Found non identical" not in caplog.text - assert "Found duplicated" not in caplog.text - - -def test_duplicate_statistics_handle_integrity_error( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture -) -> None: - """Test the recorder does not blow up if statistics is duplicated.""" - hass = hass_recorder() - wait_recording_done(hass) - - period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) - - external_energy_metadata_1 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_1", - "unit_of_measurement": "kWh", - } - external_energy_statistics_1 = [ - { - "start": period1, - "last_reset": None, - "state": 3, - "sum": 5, - }, - ] - external_energy_statistics_2 = [ - { - "start": period2, - "last_reset": None, - "state": 3, - "sum": 6, - } - ] - - with patch.object( - statistics, "_statistics_exists", return_value=False - ), patch.object( - statistics, "_insert_statistics", wraps=statistics._insert_statistics - ) as insert_statistics_mock: - async_add_external_statistics( - hass, external_energy_metadata_1, external_energy_statistics_1 - ) - async_add_external_statistics( - hass, external_energy_metadata_1, external_energy_statistics_1 - ) - async_add_external_statistics( - hass, external_energy_metadata_1, external_energy_statistics_2 - ) - wait_recording_done(hass) - assert insert_statistics_mock.call_count == 3 - - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.Statistics).all() - assert len(tmp) == 2 - - assert "Blocked attempt to insert duplicated statistic rows" in caplog.text - - -def _create_engine_28(*args, **kwargs): - """Test version of create_engine that initializes with old schema. - - This simulates an existing db with the old schema. - """ - module = "tests.components.recorder.db_schema_28" - importlib.import_module(module) - old_db_schema = sys.modules[module] - engine = create_engine(*args, **kwargs) - old_db_schema.Base.metadata.create_all(engine) - with Session(engine) as session: - session.add( - recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) - ) - session.add( - recorder.db_schema.SchemaChanges( - schema_version=old_db_schema.SCHEMA_VERSION - ) - ) - session.commit() - return engine - - -def test_delete_metadata_duplicates( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local -) -> None: - """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") - dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - - module = "tests.components.recorder.db_schema_28" - importlib.import_module(module) - old_db_schema = sys.modules[module] - - external_energy_metadata_1 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_1", - "unit_of_measurement": "kWh", - } - external_energy_metadata_2 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_1", - "unit_of_measurement": "kWh", - } - external_co2_metadata = { - "has_mean": True, - "has_sum": False, - "name": "Fossil percentage", - "source": "test", - "statistic_id": "test:fossil_percentage", - "unit_of_measurement": "%", - } - - # Create some duplicated statistics_meta with schema version 28 - with patch.object(recorder, "db_schema", old_db_schema), patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch( - "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 - ): - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - wait_recording_done(hass) - wait_recording_done(hass) - - with session_scope(hass=hass) as session: - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) - ) - - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.StatisticsMeta).all() - assert len(tmp) == 3 - assert tmp[0].id == 1 - assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[1].id == 2 - assert tmp[1].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[2].id == 3 - assert tmp[2].statistic_id == "test:fossil_percentage" - - hass.stop() - dt_util.DEFAULT_TIME_ZONE = ORIG_TZ - - # Test that the duplicates are removed during migration from schema 28 - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) - - assert "Deleted 1 duplicated statistics_meta rows" in caplog.text - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.StatisticsMeta).all() - assert len(tmp) == 2 - assert tmp[0].id == 2 - assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[1].id == 3 - assert tmp[1].statistic_id == "test:fossil_percentage" - - hass.stop() - dt_util.DEFAULT_TIME_ZONE = ORIG_TZ - - -def test_delete_metadata_duplicates_many( - caplog: pytest.LogCaptureFixture, tmpdir: py.path.local -) -> None: - """Test removal of duplicated statistics.""" - test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") - dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - - module = "tests.components.recorder.db_schema_28" - importlib.import_module(module) - old_db_schema = sys.modules[module] - - external_energy_metadata_1 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_1", - "unit_of_measurement": "kWh", - } - external_energy_metadata_2 = { - "has_mean": False, - "has_sum": True, - "name": "Total imported energy", - "source": "test", - "statistic_id": "test:total_energy_import_tariff_2", - "unit_of_measurement": "kWh", - } - external_co2_metadata = { - "has_mean": True, - "has_sum": False, - "name": "Fossil percentage", - "source": "test", - "statistic_id": "test:fossil_percentage", - "unit_of_measurement": "%", - } - - # Create some duplicated statistics with schema version 28 - with patch.object(recorder, "db_schema", old_db_schema), patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), patch( - "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 - ): - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - wait_recording_done(hass) - wait_recording_done(hass) - - with session_scope(hass=hass) as session: - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) - ) - for _ in range(1100): - session.add( - recorder.db_schema.StatisticsMeta.from_meta( - external_energy_metadata_1 - ) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) - ) - session.add( - recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) - ) - - hass.stop() - dt_util.DEFAULT_TIME_ZONE = ORIG_TZ - - # Test that the duplicates are removed during migration from schema 28 - hass = get_test_home_assistant() - recorder_helper.async_initialize_recorder(hass) - setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) - hass.start() - wait_recording_done(hass) - wait_recording_done(hass) - - assert "Deleted 1102 duplicated statistics_meta rows" in caplog.text - with session_scope(hass=hass) as session: - tmp = session.query(recorder.db_schema.StatisticsMeta).all() - assert len(tmp) == 3 - assert tmp[0].id == 1101 - assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" - assert tmp[1].id == 1103 - assert tmp[1].statistic_id == "test:total_energy_import_tariff_2" - assert tmp[2].id == 1105 - assert tmp[2].statistic_id == "test:fossil_percentage" - - hass.stop() - dt_util.DEFAULT_TIME_ZONE = ORIG_TZ - - -def test_delete_metadata_duplicates_no_duplicates( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture -) -> None: - """Test removal of duplicated statistics.""" - hass = hass_recorder() - wait_recording_done(hass) - with session_scope(hass=hass) as session: - instance = recorder.get_instance(hass) - delete_statistics_meta_duplicates(instance, session) - assert "duplicated statistics_meta rows" not in caplog.text - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -async def test_validate_db_schema( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, -) -> None: - """Test validating DB schema with MySQL and PostgreSQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ): - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - assert "Schema validation failed" not in caplog.text - assert "Detected statistics schema errors" not in caplog.text - assert "Database is about to correct DB schema errors" not in caplog.text - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -async def test_validate_db_schema_fix_utf8_issue( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test validating DB schema with MySQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - orig_error = MagicMock() - orig_error.args = [1366] - utf8_error = OperationalError("", "", orig=orig_error) - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" - ), patch( - "homeassistant.components.recorder.table_managers.statistics_meta.StatisticsMetaManager.update_or_add", - wraps=StatisticsMetaManager.update_or_add, - side_effect=[utf8_error, DEFAULT, DEFAULT], - ): - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - assert "Schema validation failed" not in caplog.text - assert ( - "Database is about to correct DB schema errors: statistics_meta.4-byte UTF-8" - in caplog.text - ) - assert ( - "Updating character set and collation of table statistics_meta to utf8mb4" - in caplog.text - ) - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - (("max", 1.0), ("mean", 1.0), ("min", 1.0), ("state", 1.0), ("sum", 1.0)), -) -async def test_validate_db_schema_fix_float_issue( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, - table, - replace_index, - column, - value, -) -> None: - """Test validating DB schema with MySQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - fixed_future_year = _get_future_year() - precise_time = datetime(fixed_future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time.timestamp(), - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time.timestamp(), - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( - "homeassistant.components.recorder.statistics._get_future_year", - return_value=fixed_future_year, - ), patch( - "homeassistant.components.recorder.statistics._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, - ), patch( - "homeassistant.components.recorder.migration._modify_columns" - ) as modify_columns_mock: - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - assert "Schema validation failed" not in caplog.text - assert ( - f"Database is about to correct DB schema errors: {table}.double precision" - in caplog.text - ) - modification = [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", - ] - modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize( - ("db_engine", "modification"), - ( - ("mysql", ["last_reset_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION"]), - ( - "postgresql", - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ), - ), -) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - ( - ("last_reset", "2020-10-06T00:00:00+00:00"), - ("start", "2020-10-06T00:00:00+00:00"), - ), -) -async def test_validate_db_schema_fix_statistics_datetime_issue( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, - modification, - table, - replace_index, - column, - value, -) -> None: - """Test validating DB schema with MySQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( - "homeassistant.components.recorder.statistics._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, - ), patch( - "homeassistant.components.recorder.migration._modify_columns" - ) as modify_columns_mock: - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - assert "Schema validation failed" not in caplog.text - assert ( - f"Database is about to correct DB schema errors: {table}.µs precision" - in caplog.text - ) - modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) - - def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) diff --git a/tests/conftest.py b/tests/conftest.py index 5a1c44b78193..c5197dd2bd26 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1282,13 +1282,16 @@ def hass_recorder( # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.recorder.auto_repairs.statistics import schema + original_tz = dt_util.DEFAULT_TIME_ZONE hass = get_test_home_assistant() nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None stats_validate = ( - recorder.statistics.validate_db_schema + schema.validate_db_schema if enable_statistics_table_validation else itertools.repeat(set()) ) @@ -1397,13 +1400,16 @@ async def async_setup_recorder_instance( # pylint: disable-next=import-outside-toplevel from homeassistant.components import recorder + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.recorder.auto_repairs.statistics import schema + # pylint: disable-next=import-outside-toplevel from .components.recorder.common import async_recorder_block_till_done nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None stats_validate = ( - recorder.statistics.validate_db_schema + schema.validate_db_schema if enable_statistics_table_validation else itertools.repeat(set()) ) From 0e7ffff869cf9711d85c895ec3527977ce411c2e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 21 Mar 2023 20:10:31 -0500 Subject: [PATCH 0669/1058] Add TTS to pipelines (#90004) * Add text to speech and stages to pipeline * Default to "cloud" TTS when engine is None * Refactor pipeline request to split text/audio * Refactor with PipelineRun * Generate pipeline from language * Clean up * Restore TTS code * Add audio pipeline test * Clean TTS cache in test * Clean up tests and pipeline base class * Stop pylint and pytest magics from fighting * Include mock_get_cache_files --- .../components/voice_assistant/__init__.py | 11 +- .../components/voice_assistant/pipeline.py | 177 +++++++++++++----- .../voice_assistant/websocket_api.py | 50 +++-- .../voice_assistant/test_pipeline.py | 110 +++++++++++ .../voice_assistant/test_websocket.py | 15 +- 5 files changed, 289 insertions(+), 74 deletions(-) create mode 100644 tests/components/voice_assistant/test_pipeline.py diff --git a/homeassistant/components/voice_assistant/__init__.py b/homeassistant/components/voice_assistant/__init__.py index d06176847e96..2ae169a28eb4 100644 --- a/homeassistant/components/voice_assistant/__init__.py +++ b/homeassistant/components/voice_assistant/__init__.py @@ -4,20 +4,13 @@ from __future__ import annotations from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from .const import DEFAULT_PIPELINE, DOMAIN -from .pipeline import Pipeline +from .const import DOMAIN from .websocket_api import async_register_websocket_api async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Voice Assistant integration.""" - hass.data[DOMAIN] = { - DEFAULT_PIPELINE: Pipeline( - name=DEFAULT_PIPELINE, - language=None, - conversation_engine=None, - ) - } + hass.data[DOMAIN] = {} async_register_websocket_api(hass) return True diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 8c7d22981abe..0b55d724554a 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -1,6 +1,7 @@ """Classes for voice assistant pipelines.""" from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from collections.abc import Callable from dataclasses import dataclass, field @@ -8,20 +9,16 @@ from typing import Any from homeassistant.backports.enum import StrEnum from homeassistant.components import conversation +from homeassistant.components.media_source import async_resolve_media +from homeassistant.components.tts.media_source import ( + generate_media_source_id as tts_generate_media_source_id, +) from homeassistant.core import Context, HomeAssistant from homeassistant.util.dt import utcnow DEFAULT_TIMEOUT = 30 # seconds -@dataclass -class PipelineRequest: - """Request to start a pipeline run.""" - - intent_input: str - conversation_id: str | None = None - - class PipelineEventType(StrEnum): """Event types emitted during a pipeline run.""" @@ -29,6 +26,8 @@ class PipelineEventType(StrEnum): RUN_FINISH = "run-finish" INTENT_START = "intent-start" INTENT_FINISH = "intent-finish" + TTS_START = "tts-start" + TTS_FINISH = "tts-finish" ERROR = "error" @@ -56,69 +55,161 @@ class Pipeline: name: str language: str | None conversation_engine: str | None + tts_engine: str | None - async def run( - self, - hass: HomeAssistant, - context: Context, - request: PipelineRequest, - event_callback: Callable[[PipelineEvent], None], - timeout: int | float | None = DEFAULT_TIMEOUT, - ) -> None: - """Run a pipeline with an optional timeout.""" - await asyncio.wait_for( - self._run(hass, context, request, event_callback), timeout=timeout - ) - async def _run( - self, - hass: HomeAssistant, - context: Context, - request: PipelineRequest, - event_callback: Callable[[PipelineEvent], None], - ) -> None: - """Run a pipeline.""" - language = self.language or hass.config.language - event_callback( +@dataclass +class PipelineRun: + """Running context for a pipeline.""" + + hass: HomeAssistant + context: Context + pipeline: Pipeline + event_callback: Callable[[PipelineEvent], None] + language: str = None # type: ignore[assignment] + + def __post_init__(self): + """Set language for pipeline.""" + self.language = self.pipeline.language or self.hass.config.language + + def start(self): + """Emit run start event.""" + self.event_callback( PipelineEvent( PipelineEventType.RUN_START, { - "pipeline": self.name, - "language": language, + "pipeline": self.pipeline.name, + "language": self.language, }, ) ) - intent_input = request.intent_input + def finish(self): + """Emit run finish event.""" + self.event_callback( + PipelineEvent( + PipelineEventType.RUN_FINISH, + ) + ) - event_callback( + async def recognize_intent( + self, intent_input: str, conversation_id: str | None + ) -> conversation.ConversationResult: + """Run intent recognition portion of pipeline.""" + self.event_callback( PipelineEvent( PipelineEventType.INTENT_START, { - "engine": self.conversation_engine or "default", + "engine": self.pipeline.conversation_engine or "default", "intent_input": intent_input, }, ) ) conversation_result = await conversation.async_converse( - hass=hass, + hass=self.hass, text=intent_input, - conversation_id=request.conversation_id, - context=context, - language=language, - agent_id=self.conversation_engine, + conversation_id=conversation_id, + context=self.context, + language=self.language, + agent_id=self.pipeline.conversation_engine, ) - event_callback( + self.event_callback( PipelineEvent( PipelineEventType.INTENT_FINISH, {"intent_output": conversation_result.as_dict()}, ) ) - event_callback( + return conversation_result + + async def text_to_speech(self, tts_input: str) -> str: + """Run text to speech portion of pipeline. Returns URL of TTS audio.""" + self.event_callback( PipelineEvent( - PipelineEventType.RUN_FINISH, + PipelineEventType.TTS_START, + { + "engine": self.pipeline.tts_engine or "default", + "tts_input": tts_input, + }, ) ) + + tts_media = await async_resolve_media( + self.hass, + tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.pipeline.tts_engine, + ), + ) + tts_url = tts_media.url + + self.event_callback( + PipelineEvent( + PipelineEventType.TTS_FINISH, + {"tts_output": tts_url}, + ) + ) + + return tts_url + + +@dataclass +class PipelineRequest(ABC): + """Request to for a pipeline run.""" + + async def execute( + self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT + ): + """Run pipeline with optional timeout.""" + await asyncio.wait_for( + self._execute(run), + timeout=timeout, + ) + + @abstractmethod + async def _execute(self, run: PipelineRun): + """Run pipeline with request info and context.""" + + +@dataclass +class TextPipelineRequest(PipelineRequest): + """Request to run the text portion only of a pipeline.""" + + intent_input: str + conversation_id: str | None = None + + async def _execute( + self, + run: PipelineRun, + ): + run.start() + await run.recognize_intent(self.intent_input, self.conversation_id) + run.finish() + + +@dataclass +class AudioPipelineRequest(PipelineRequest): + """Request to full pipeline from audio input (stt) to audio output (tts).""" + + intent_input: str # this will be changed to stt audio + conversation_id: str | None = None + + async def _execute(self, run: PipelineRun): + run.start() + + # stt will go here + + conversation_result = await run.recognize_intent( + self.intent_input, self.conversation_id + ) + + tts_input = conversation_result.response.speech.get("plain", {}).get( + "speech", "" + ) + + await run.text_to_speech(tts_input) + + run.finish() diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 4ea88c3da00c..54e87e292a17 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -7,7 +7,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from .const import DOMAIN -from .pipeline import DEFAULT_TIMEOUT, PipelineRequest +from .pipeline import DEFAULT_TIMEOUT, Pipeline, PipelineRun, TextPipelineRequest @callback @@ -19,7 +19,8 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", - vol.Optional("pipeline", default="default"): str, + vol.Optional("language"): str, + vol.Optional("pipeline"): str, vol.Required("intent_input"): str, vol.Optional("conversation_id"): vol.Any(str, None), vol.Optional("timeout"): vol.Any(float, int), @@ -32,27 +33,42 @@ async def websocket_run( msg: dict[str, Any], ) -> None: """Run a pipeline.""" - pipeline_id = msg["pipeline"] - pipeline = hass.data[DOMAIN].get(pipeline_id) - if pipeline is None: - connection.send_error( - msg["id"], "pipeline_not_found", f"Pipeline not found: {pipeline_id}" + pipeline_id = msg.get("pipeline") + if pipeline_id is not None: + pipeline = hass.data[DOMAIN].get(pipeline_id) + if pipeline is None: + connection.send_error( + msg["id"], + "pipeline_not_found", + f"Pipeline not found: {pipeline_id}", + ) + return + + else: + # Construct a pipeline for the required/configured language + language = msg.get("language", hass.config.language) + pipeline = Pipeline( + name=language, + language=language, + conversation_engine=None, + tts_engine=None, ) - return # Run pipeline with a timeout. # Events are sent over the websocket connection. timeout = msg.get("timeout", DEFAULT_TIMEOUT) run_task = hass.async_create_task( - pipeline.run( - hass, - connection.context(msg), - request=PipelineRequest( - intent_input=msg["intent_input"], - conversation_id=msg.get("conversation_id"), - ), - event_callback=lambda event: connection.send_event( - msg["id"], event.as_dict() + TextPipelineRequest( + intent_input=msg["intent_input"], + conversation_id=msg.get("conversation_id"), + ).execute( + PipelineRun( + hass, + connection.context(msg), + pipeline, + event_callback=lambda event: connection.send_event( + msg["id"], event.as_dict() + ), ), timeout=timeout, ) diff --git a/tests/components/voice_assistant/test_pipeline.py b/tests/components/voice_assistant/test_pipeline.py new file mode 100644 index 000000000000..343719a49fd2 --- /dev/null +++ b/tests/components/voice_assistant/test_pipeline.py @@ -0,0 +1,110 @@ +"""Pipeline tests for Voice Assistant integration.""" +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.voice_assistant.pipeline import ( + AudioPipelineRequest, + Pipeline, + PipelineEventType, + PipelineRun, +) +from homeassistant.core import Context +from homeassistant.setup import async_setup_component + +from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import + mock_get_cache_files, + mock_init_cache_dir, +) + + +@pytest.fixture(autouse=True) +async def init_components(hass): + """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "voice_assistant", {}) + + +@pytest.fixture +async def mock_get_tts_audio(hass): + """Set up media source.""" + assert await async_setup_component(hass, "media_source", {}) + assert await async_setup_component( + hass, + "tts", + { + "tts": { + "platform": "demo", + } + }, + ) + + with patch( + "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", + return_value=("mp3", b""), + ) as mock_get_tts: + yield mock_get_tts + + +async def test_audio_pipeline(hass, mock_get_tts_audio): + """Run audio pipeline with mock TTS.""" + pipeline = Pipeline( + name="test", + language=hass.config.language, + conversation_engine=None, + tts_engine=None, + ) + + event_callback = MagicMock() + await AudioPipelineRequest(intent_input="Are the lights on?").execute( + PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + event_callback=event_callback, + language=hass.config.language, + ) + ) + + calls = event_callback.mock_calls + assert calls[0].args[0].type == PipelineEventType.RUN_START + assert calls[0].args[0].data == { + "pipeline": "test", + "language": hass.config.language, + } + + assert calls[1].args[0].type == PipelineEventType.INTENT_START + assert calls[1].args[0].data == { + "engine": "default", + "intent_input": "Are the lights on?", + } + assert calls[2].args[0].type == PipelineEventType.INTENT_FINISH + assert calls[2].args[0].data == { + "intent_output": { + "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 calls[3].args[0].type == PipelineEventType.TTS_START + assert calls[3].args[0].data == { + "engine": "default", + "tts_input": "Sorry, I couldn't understand that", + } + assert calls[4].args[0].type == PipelineEventType.TTS_FINISH + assert ( + calls[4].args[0].data["tts_output"] + == f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3" + ) + + assert calls[5].args[0].type == PipelineEventType.RUN_FINISH diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index e862da6f5420..2fec6cdfb03b 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -24,7 +24,11 @@ async def test_text_only_pipeline( client = await hass_ws_client(hass) await client.send_json( - {"id": 5, "type": "voice_assistant/run", "intent_input": "Are the lights on?"} + { + "id": 5, + "type": "voice_assistant/run", + "intent_input": "Are the lights on?", + } ) # result @@ -35,7 +39,7 @@ async def test_text_only_pipeline( msg = await client.receive_json() assert msg["event"]["type"] == "run-start" assert msg["event"]["data"] == { - "pipeline": "default", + "pipeline": hass.config.language, "language": hass.config.language, } @@ -83,7 +87,8 @@ async def test_conversation_timeout( await asyncio.sleep(3600) with patch( - "homeassistant.components.conversation.async_converse", new=sleepy_converse + "homeassistant.components.conversation.async_converse", + new=sleepy_converse, ): await client.send_json( { @@ -102,7 +107,7 @@ async def test_conversation_timeout( msg = await client.receive_json() assert msg["event"]["type"] == "run-start" assert msg["event"]["data"] == { - "pipeline": "default", + "pipeline": hass.config.language, "language": hass.config.language, } @@ -130,7 +135,7 @@ async def test_pipeline_timeout( await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.Pipeline._run", + "homeassistant.components.voice_assistant.pipeline.TextPipelineRequest._execute", new=sleepy_run, ): await client.send_json( From 88ad97f112bd1088a767d3d8181bad52541cc561 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 15:12:45 -1000 Subject: [PATCH 0670/1058] Fix generating statistics for time periods smaller than we can measure (#90069) If the time period for the mean/time weighted average was smaller than we can measure (less than one microsecond), generating statistics would fail with a divide by zero error. This is likely only happens if the database schema precision is incorrect. --- homeassistant/components/sensor/recorder.py | 11 +- tests/components/sensor/test_recorder.py | 334 ++++++++++++++++++++ 2 files changed, 344 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index c0df642ed361..21fbf453ac3f 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -119,7 +119,16 @@ def _time_weighted_average( duration = end - old_start_time accumulated += old_fstate * duration.total_seconds() - return accumulated / (end - start).total_seconds() + period_seconds = (end - start).total_seconds() + if period_seconds == 0: + # If the only state changed that happened was at the exact moment + # at the end of the period, we can't calculate a meaningful average + # so we return 0.0 since it represents a time duration smaller than + # we can measure. This probably means the precision of statistics + # column schema in the database is incorrect but it is actually possible + # to happen if the state change event fired at the exact microsecond + return 0.0 + return accumulated / period_seconds def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 8881bef8edc9..f3e373f5a631 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -193,6 +193,340 @@ def test_compile_hourly_statistics( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + ("temperature", "°C", "°C", "°C", "temperature", 27.796610169491526, -10, 60), + ("temperature", "°F", "°F", "°F", "temperature", 27.796610169491526, -10, 60), + ], +) +def test_compile_hourly_statistics_with_some_same_last_updated( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics with the some of the same last updated value. + + If the last updated value is the same we will have a zero duration. + """ + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + entity_id = "sensor.test1" + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + attributes = dict(attributes) + seq = [-10, 15, 30, 60] + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + + states = {entity_id: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one + ): + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) + + # Record two states at the exact same time + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two + ): + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three + ): + states[entity_id].append( + set_state(entity_id, str(seq[3]), attributes=attributes) + ) + + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + ("temperature", "°C", "°C", "°C", "temperature", 60, -10, 60), + ("temperature", "°F", "°F", "°F", "temperature", 60, -10, 60), + ], +) +def test_compile_hourly_statistics_with_all_same_last_updated( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics with the all of the same last updated value. + + If the last updated value is the same we will have a zero duration. + """ + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + entity_id = "sensor.test1" + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + attributes = dict(attributes) + seq = [-10, 15, 30, 60] + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + + states = {entity_id: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two + ): + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[3]), attributes=attributes) + ) + + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize( + ( + "device_class", + "state_unit", + "display_unit", + "statistics_unit", + "unit_class", + "mean", + "min", + "max", + ), + [ + ("temperature", "°C", "°C", "°C", "temperature", 0, 60, 60), + ("temperature", "°F", "°F", "°F", "temperature", 0, 60, 60), + ], +) +def test_compile_hourly_statistics_only_state_is_and_end_of_period( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + device_class, + state_unit, + display_unit, + statistics_unit, + unit_class, + mean, + min, + max, +) -> None: + """Test compiling hourly statistics when the only state at end of period.""" + zero = dt_util.utcnow() + hass = hass_recorder() + setup_component(hass, "sensor", {}) + wait_recording_done(hass) # Wait for the sensor recorder platform to be added + entity_id = "sensor.test1" + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": state_unit, + } + attributes = dict(attributes) + seq = [-10, 15, 30, 60] + + def set_state(entity_id, state, **kwargs): + """Set the state.""" + hass.states.set(entity_id, state, **kwargs) + wait_recording_done(hass) + return hass.states.get(entity_id) + + one = zero + timedelta(seconds=1 * 5) + two = one + timedelta(seconds=10 * 5) + three = two + timedelta(seconds=40 * 5) + four = three + timedelta(seconds=10 * 5) + end = zero + timedelta(minutes=5) + + states = {entity_id: []} + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end + ): + states[entity_id].append( + set_state(entity_id, str(seq[0]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[1]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[2]), attributes=attributes) + ) + states[entity_id].append( + set_state(entity_id, str(seq[3]), attributes=attributes) + ) + + hist = history.get_significant_states(hass, zero, four) + assert_dict_of_states_equal_without_context_and_last_changed(states, hist) + + do_adhoc_statistics(hass, start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + { + "statistic_id": "sensor.test1", + "display_unit_of_measurement": display_unit, + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": statistics_unit, + "unit_class": unit_class, + } + ] + stats = statistics_during_period(hass, zero, period="5minute") + assert stats == { + "sensor.test1": [ + { + "start": process_timestamp(zero).timestamp(), + "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), + "mean": pytest.approx(mean), + "min": pytest.approx(min), + "max": pytest.approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + @pytest.mark.parametrize( ("device_class", "state_unit", "display_unit", "statistics_unit", "unit_class"), [ From 1439a3d572f4ef3fb9f8a7b674d7dc8c3797f8f1 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 21 Mar 2023 23:09:38 -0400 Subject: [PATCH 0671/1058] Bump to oralb-ble 0.17.6 (#90081) --- homeassistant/components/oralb/manifest.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index 37b043e54364..a1071cc0a11c 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,5 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "requirements": ["oralb-ble==0.17.5"] + "loggers": ["oralb-ble"], + "requirements": ["oralb-ble==0.17.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5395f1104ed9..a19f989c5841 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1299,7 +1299,7 @@ openwrt-luci-rpc==1.1.11 openwrt-ubus-rpc==0.0.2 # homeassistant.components.oralb -oralb-ble==0.17.5 +oralb-ble==0.17.6 # homeassistant.components.oru oru==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 265ec1eeab60..639b7e2d4ded 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ openai==0.26.2 openerz-api==0.2.0 # homeassistant.components.oralb -oralb-ble==0.17.5 +oralb-ble==0.17.6 # homeassistant.components.ovo_energy ovoenergy==1.2.0 From 96225bb287cabd52791300c21ce06a70886f8f3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Mar 2023 18:38:33 -1000 Subject: [PATCH 0672/1058] Rename recorder run_history to table_managers.recorder_runs_manager (#90070) --- homeassistant/components/recorder/core.py | 18 +++++++------- .../components/recorder/history/legacy.py | 2 +- .../components/recorder/history/modern.py | 2 +- homeassistant/components/recorder/purge.py | 4 +++- .../recorder/system_health/__init__.py | 6 ++--- .../recorder_runs.py} | 6 ++--- homeassistant/components/recorder/tasks.py | 2 +- .../test_recorder_runs.py} | 24 ++++++++++++------- tests/components/recorder/test_init.py | 4 ++-- tests/components/recorder/test_migrate.py | 2 +- .../components/recorder/test_system_health.py | 20 +++++++++------- 11 files changed, 51 insertions(+), 39 deletions(-) rename homeassistant/components/recorder/{run_history.py => table_managers/recorder_runs.py} (97%) rename tests/components/recorder/{test_run_history.py => table_managers/test_recorder_runs.py} (76%) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 893117f6e321..bbdab2690d17 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -80,9 +80,9 @@ from .queries import ( has_events_context_ids_to_migrate, has_states_context_ids_to_migrate, ) -from .run_history import RunHistory from .table_managers.event_data import EventDataManager from .table_managers.event_types import EventTypeManager +from .table_managers.recorder_runs import RecorderRunsManager from .table_managers.state_attributes import StateAttributesManager from .table_managers.states import StatesManager from .table_managers.states_meta import StatesMetaManager @@ -198,7 +198,6 @@ class Recorder(threading.Thread): self.async_recorder_ready = asyncio.Event() self._queue_watch = threading.Event() self.engine: Engine | None = None - self.run_history = RunHistory() # The entity_filter is exposed on the recorder instance so that # it can be used to see if an entity is being recorded and is called @@ -208,6 +207,8 @@ class Recorder(threading.Thread): self.schema_version = 0 self._commits_without_expire = 0 + + self.recorder_runs_manager = RecorderRunsManager() self.states_manager = StatesManager() self.event_data_manager = EventDataManager(self) self.event_type_manager = EventTypeManager(self) @@ -216,6 +217,7 @@ class Recorder(threading.Thread): self, exclude_attributes_by_domain ) self.statistics_meta_manager = StatisticsMetaManager(self) + self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None self._completed_first_database_setup: bool | None = None @@ -1117,7 +1119,7 @@ class Recorder(threading.Thread): finally: self._close_connection() move_away_broken_database(dburl_to_path(self.db_url)) - self.run_history.reset() + self.recorder_runs_manager.reset() self._setup_recorder() self._setup_run() @@ -1333,8 +1335,8 @@ class Recorder(threading.Thread): def _setup_run(self) -> None: """Log the start of the current run and schedule any needed jobs.""" with session_scope(session=self.get_session()) as session: - end_incomplete_runs(session, self.run_history.recording_start) - self.run_history.start(session) + end_incomplete_runs(session, self.recorder_runs_manager.recording_start) + self.recorder_runs_manager.start(session) self._open_event_session() @@ -1346,15 +1348,15 @@ class Recorder(threading.Thread): """End the recorder session.""" if self.event_session is None: return - if self.run_history.active: - self.run_history.end(self.event_session) + if self.recorder_runs_manager.active: + self.recorder_runs_manager.end(self.event_session) try: self._commit_event_session_or_retry() except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Error saving the event session during shutdown: %s", err) self.event_session.close() - self.run_history.clear() + self.recorder_runs_manager.clear() def _shutdown(self) -> None: """Save end time for current run.""" diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index b8f27211c717..e51b1a256860 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -742,7 +742,7 @@ def _get_rows_with_session( ) if run is None: - run = recorder.get_instance(hass).run_history.get(utc_point_in_time) + run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) if run is None or process_timestamp(run.start) > utc_point_in_time: # History did not run before utc_point_in_time diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index d39abd2e9f34..50e61027036a 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -577,7 +577,7 @@ def _get_rows_with_session( ) if run is None: - run = recorder.get_instance(hass).run_history.get(utc_point_in_time) + run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) if run is None or process_timestamp(run.start) > utc_point_in_time: # History did not run before utc_point_in_time diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index fafb7c661a93..662be41b1c8e 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -517,7 +517,9 @@ def _purge_old_recorder_runs( """Purge all old recorder runs.""" # Recorder runs is small, no need to batch run it deleted_rows = session.execute( - delete_recorder_runs_rows(purge_before, instance.run_history.current.run_id) + delete_recorder_runs_rows( + purge_before, instance.recorder_runs_manager.current.run_id + ) ) _LOGGER.debug("Deleted %s recorder_runs", deleted_rows) diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index 76542a0c1738..a3545ec2c894 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -58,7 +58,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" instance = get_instance(hass) - run_history = instance.run_history + recorder_runs_manager = instance.recorder_runs_manager database_name = urlparse(instance.db_url).path.lstrip("/") db_engine_info = _async_get_db_engine_info(instance) db_stats: dict[str, Any] = {} @@ -68,7 +68,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: _get_db_stats, instance, database_name ) db_runs = { - "oldest_recorder_run": run_history.first.start, - "current_recorder_run": run_history.current.start, + "oldest_recorder_run": recorder_runs_manager.first.start, + "current_recorder_run": recorder_runs_manager.current.start, } return db_runs | db_stats | db_engine_info diff --git a/homeassistant/components/recorder/run_history.py b/homeassistant/components/recorder/table_managers/recorder_runs.py similarity index 97% rename from homeassistant/components/recorder/run_history.py rename to homeassistant/components/recorder/table_managers/recorder_runs.py index b424c9999953..455c8375b1cf 100644 --- a/homeassistant/components/recorder/run_history.py +++ b/homeassistant/components/recorder/table_managers/recorder_runs.py @@ -9,8 +9,8 @@ from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util -from .db_schema import RecorderRuns -from .models import process_timestamp +from ..db_schema import RecorderRuns +from ..models import process_timestamp def _find_recorder_run_for_start_time( @@ -40,7 +40,7 @@ class _RecorderRunsHistory: runs_by_timestamp: dict[int, RecorderRuns] -class RunHistory: +class RecorderRunsManager: """Track recorder run history.""" def __init__(self) -> None: diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index d3e3c053825f..7b8fa4867b6f 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -113,7 +113,7 @@ class PurgeTask(RecorderTask): instance, self.purge_before, self.repack, self.apply_filter ): with instance.get_session() as session: - instance.run_history.load_from_db(session) + instance.recorder_runs_manager.load_from_db(session) # We always need to do the db cleanups after a purge # is finished to ensure the WAL checkpoint and other # tasks happen after a vacuum. diff --git a/tests/components/recorder/test_run_history.py b/tests/components/recorder/table_managers/test_recorder_runs.py similarity index 76% rename from tests/components/recorder/test_run_history.py rename to tests/components/recorder/table_managers/test_recorder_runs.py index 9c7db9ca1b22..2946850ec117 100644 --- a/tests/components/recorder/test_run_history.py +++ b/tests/components/recorder/table_managers/test_recorder_runs.py @@ -1,4 +1,4 @@ -"""Test run history.""" +"""Test recorder runs table manager.""" from datetime import timedelta from unittest.mock import patch @@ -25,29 +25,35 @@ async def test_run_history(recorder_mock: Recorder, hass: HomeAssistant) -> None session.add(RecorderRuns(start=two_days_ago, created=two_days_ago)) session.add(RecorderRuns(start=one_day_ago, created=one_day_ago)) session.commit() - instance.run_history.load_from_db(session) + instance.recorder_runs_manager.load_from_db(session) assert ( process_timestamp( - instance.run_history.get(three_days_ago + timedelta(microseconds=1)).start + instance.recorder_runs_manager.get( + three_days_ago + timedelta(microseconds=1) + ).start ) == three_days_ago ) assert ( process_timestamp( - instance.run_history.get(two_days_ago + timedelta(microseconds=1)).start + instance.recorder_runs_manager.get( + two_days_ago + timedelta(microseconds=1) + ).start ) == two_days_ago ) assert ( process_timestamp( - instance.run_history.get(one_day_ago + timedelta(microseconds=1)).start + instance.recorder_runs_manager.get( + one_day_ago + timedelta(microseconds=1) + ).start ) == one_day_ago ) assert ( - process_timestamp(instance.run_history.get(now).start) - == instance.run_history.recording_start + process_timestamp(instance.recorder_runs_manager.get(now).start) + == instance.recorder_runs_manager.recording_start ) @@ -64,10 +70,10 @@ async def test_run_history_while_recorder_is_not_yet_started( # Prevent the run history from starting to ensure # we can test run_history.current.start returns the expected value with patch( - "homeassistant.components.recorder.run_history.RunHistory.start", + "homeassistant.components.recorder.table_managers.recorder_runs.RecorderRunsManager.start", ): instance = await async_setup_recorder_instance(hass) - run_history = instance.run_history + run_history = instance.recorder_runs_manager assert run_history.current.start == run_history.recording_start def _start_run_history(): diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 48429d7a11d8..3232b10fdce8 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -1551,7 +1551,7 @@ async def test_database_corruption_while_running( await hass.async_block_till_done() caplog.clear() - original_start_time = get_instance(hass).run_history.recording_start + original_start_time = get_instance(hass).recorder_runs_manager.recording_start hass.states.async_set("test.lost", "on", {}) @@ -1599,7 +1599,7 @@ async def test_database_corruption_while_running( assert state.entity_id == "test.two" assert state.state == "on" - new_start_time = get_instance(hass).run_history.recording_start + new_start_time = get_instance(hass).recorder_runs_manager.recording_start assert original_start_time < new_start_time hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index e030ef1629d6..b23b7a2dfc98 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -348,7 +348,7 @@ async def test_schema_migrate( def _mock_setup_run(self): self.run_info = RecorderRuns( - start=self.run_history.recording_start, created=dt_util.utcnow() + start=self.recorder_runs_manager.recording_start, created=dt_util.utcnow() ) def _instrument_migrate_schema(*args): diff --git a/tests/components/recorder/test_system_health.py b/tests/components/recorder/test_system_health.py index 6a8815d499f9..5adacaf0ab6c 100644 --- a/tests/components/recorder/test_system_health.py +++ b/tests/components/recorder/test_system_health.py @@ -27,8 +27,8 @@ async def test_recorder_system_health( info = await get_system_health_info(hass, "recorder") instance = get_instance(hass) assert info == { - "current_recorder_run": instance.run_history.current.start, - "oldest_recorder_run": instance.run_history.first.start, + "current_recorder_run": instance.recorder_runs_manager.current.start, + "oldest_recorder_run": instance.recorder_runs_manager.first.start, "estimated_db_size": ANY, "database_engine": SupportedDialect.SQLITE.value, "database_version": ANY, @@ -53,8 +53,8 @@ async def test_recorder_system_health_alternate_dbms( info = await get_system_health_info(hass, "recorder") instance = get_instance(hass) assert info == { - "current_recorder_run": instance.run_history.current.start, - "oldest_recorder_run": instance.run_history.first.start, + "current_recorder_run": instance.recorder_runs_manager.current.start, + "oldest_recorder_run": instance.recorder_runs_manager.first.start, "estimated_db_size": "1.00 MiB", "database_engine": dialect_name.value, "database_version": ANY, @@ -84,8 +84,8 @@ async def test_recorder_system_health_db_url_missing_host( ): info = await get_system_health_info(hass, "recorder") assert info == { - "current_recorder_run": instance.run_history.current.start, - "oldest_recorder_run": instance.run_history.first.start, + "current_recorder_run": instance.recorder_runs_manager.current.start, + "oldest_recorder_run": instance.recorder_runs_manager.first.start, "estimated_db_size": "1.00 MiB", "database_engine": dialect_name.value, "database_version": ANY, @@ -102,14 +102,16 @@ async def test_recorder_system_health_crashed_recorder_runs_table( # This test is specific for SQLite return - with patch("homeassistant.components.recorder.run_history.RunHistory.load_from_db"): + with patch( + "homeassistant.components.recorder.table_managers.recorder_runs.RecorderRunsManager.load_from_db" + ): assert await async_setup_component(hass, "system_health", {}) instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) info = await get_system_health_info(hass, "recorder") assert info == { - "current_recorder_run": instance.run_history.current.start, - "oldest_recorder_run": instance.run_history.current.start, + "current_recorder_run": instance.recorder_runs_manager.current.start, + "oldest_recorder_run": instance.recorder_runs_manager.current.start, "estimated_db_size": ANY, "database_engine": SupportedDialect.SQLITE.value, "database_version": ANY, From d25e3943105b4b1aea985633e1175168c4a04ac9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 22 Mar 2023 09:18:09 +0100 Subject: [PATCH 0673/1058] Implement data update coordinator for nextcloud (#89652) * implement data update coordinator * apply suggestions * apply suggestions --- .../components/nextcloud/__init__.py | 57 +++------------ .../components/nextcloud/binary_sensor.py | 18 +++-- .../components/nextcloud/coordinator.py | 73 +++++++++++++++++++ homeassistant/components/nextcloud/entity.py | 27 +++---- homeassistant/components/nextcloud/sensor.py | 18 +++-- 5 files changed, 121 insertions(+), 72 deletions(-) create mode 100644 homeassistant/components/nextcloud/coordinator.py diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index b4080dd2a1ad..5dffcbf9fbac 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -13,10 +13,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -40,61 +40,28 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Nextcloud integration.""" - # Fetch Nextcloud Monitor api data conf = config[DOMAIN] try: - ncm = NextcloudMonitor(conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD]) + ncm = await hass.async_add_executor_job( + NextcloudMonitor, conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD] + ) except NextcloudMonitorError: _LOGGER.error("Nextcloud setup failed - Check configuration") return False - hass.data[DOMAIN] = get_data_points(ncm.data) - hass.data[DOMAIN]["instance"] = conf[CONF_URL] + coordinator = NextcloudDataUpdateCoordinator( + hass, + ncm, + conf, + ) + hass.data[DOMAIN] = coordinator - def nextcloud_update(event_time): - """Update data from nextcloud api.""" - try: - ncm.update() - except NextcloudMonitorError: - _LOGGER.error("Nextcloud update failed") - return False - - hass.data[DOMAIN] = get_data_points(ncm.data) - hass.data[DOMAIN]["instance"] = conf[CONF_URL] - - # Update sensors on time interval - track_time_interval(hass, nextcloud_update, conf[CONF_SCAN_INTERVAL]) + await coordinator.async_config_entry_first_refresh() for platform in PLATFORMS: discovery.load_platform(hass, platform, DOMAIN, {}, config) return True - - -# Use recursion to create list of sensors & values based on nextcloud api data -def get_data_points(api_data, key_path="", leaf=False): - """Use Recursion to discover data-points and values. - - Get dictionary of data-points by recursing through dict returned by api until - the dictionary value does not contain another dictionary and use the - resulting path of dictionary keys and resulting value as the name/value - for the data-point. - - returns: dictionary of data-point/values - """ - result = {} - for key, value in api_data.items(): - if isinstance(value, dict): - if leaf: - key_path = f"{key}_" - if not leaf: - key_path += f"{key}_" - leaf = True - result.update(get_data_points(value, key_path, leaf)) - else: - result[f"{DOMAIN}_{key_path}{key}"] = value - leaf = False - return result diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 6e0df919f90d..52ddb6600717 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -7,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity BINARY_SENSORS = ( @@ -26,11 +27,16 @@ def setup_platform( """Set up the Nextcloud sensors.""" if discovery_info is None: return - binary_sensors = [] - for name in hass.data[DOMAIN]: - if name in BINARY_SENSORS: - binary_sensors.append(NextcloudBinarySensor(name)) - add_entities(binary_sensors, True) + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] + + add_entities( + [ + NextcloudBinarySensor(coordinator, name) + for name in coordinator.data + if name in BINARY_SENSORS + ], + True, + ) class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): @@ -39,4 +45,4 @@ class NextcloudBinarySensor(NextcloudEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self._state == "yes" + return self.coordinator.data.get(self.item) == "yes" diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py new file mode 100644 index 000000000000..07dc76d41dd4 --- /dev/null +++ b/homeassistant/components/nextcloud/coordinator.py @@ -0,0 +1,73 @@ +"""Data update coordinator for the Nextcloud integration.""" + +import logging +from typing import Any + +from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError + +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Nextcloud data update coordinator.""" + + def __init__( + self, hass: HomeAssistant, ncm: NextcloudMonitor, config: ConfigType + ) -> None: + """Initialize the Nextcloud coordinator.""" + self.config = config + self.ncm = ncm + self.url = config[CONF_URL] + + super().__init__( + hass, + _LOGGER, + name=self.url, + update_interval=config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ) + + # Use recursion to create list of sensors & values based on nextcloud api data + def _get_data_points( + self, api_data: dict, key_path: str = "", leaf: bool = False + ) -> dict[str, Any]: + """Use Recursion to discover data-points and values. + + Get dictionary of data-points by recursing through dict returned by api until + the dictionary value does not contain another dictionary and use the + resulting path of dictionary keys and resulting value as the name/value + for the data-point. + + returns: dictionary of data-point/values + """ + result = {} + for key, value in api_data.items(): + if isinstance(value, dict): + if leaf: + key_path = f"{key}_" + if not leaf: + key_path += f"{key}_" + leaf = True + result.update(self._get_data_points(value, key_path, leaf)) + else: + result[f"{DOMAIN}_{key_path}{key}"] = value + leaf = False + return result + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch all Nextcloud data.""" + + def _update_data() -> None: + try: + self.ncm.update() + except NextcloudMonitorError as ex: + raise UpdateFailed from ex + + await self.hass.async_add_executor_job(_update_data) + return self._get_data_points(self.ncm.data) diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index cb066e0fcf76..54976351dd28 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -1,26 +1,23 @@ """Base entity for the Nextcloud integration.""" -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import StateType - -from .const import DOMAIN -class NextcloudEntity(Entity): +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import NextcloudDataUpdateCoordinator + + +class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): """Base Nextcloud entity.""" _attr_icon = "mdi:cloud" - def __init__(self, item: str) -> None: - """Initialize the Nextcloud entity.""" - self._attr_name = item + def __init__(self, coordinator: NextcloudDataUpdateCoordinator, item: str) -> None: + """Initialize the Nextcloud sensor.""" + super().__init__(coordinator) self.item = item - self._state: StateType = None + self._attr_name = item @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID for this sensor.""" - return f"{self.hass.data[DOMAIN]['instance']}#{self.item}" - - def update(self) -> None: - """Update the sensor.""" - self._state = self.hass.data[DOMAIN][self.item] + return f"{self.coordinator.url}#{self.item}" diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 91d4411b0cbc..459f22d30eb6 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -7,6 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from .const import DOMAIN +from .coordinator import NextcloudDataUpdateCoordinator from .entity import NextcloudEntity SENSORS = ( @@ -65,11 +66,16 @@ def setup_platform( """Set up the Nextcloud sensors.""" if discovery_info is None: return - sensors = [] - for name in hass.data[DOMAIN]: - if name in SENSORS: - sensors.append(NextcloudSensor(name)) - add_entities(sensors, True) + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] + + add_entities( + [ + NextcloudSensor(coordinator, name) + for name in coordinator.data + if name in SENSORS + ], + True, + ) class NextcloudSensor(NextcloudEntity, SensorEntity): @@ -78,4 +84,4 @@ class NextcloudSensor(NextcloudEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state for this sensor.""" - return self._state + return self.coordinator.data.get(self.item) From 214286acb93a07e95ae4094ba0499d2aa5c946cf Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 22 Mar 2023 10:23:08 +0100 Subject: [PATCH 0674/1058] Prepare MQTT platorm tests part1 (#90051) * Add help_custom_config * Tests alarm_control_panel * Tests binary_sensor * Only use help_custom_config with iterable options --- .../mqtt/test_alarm_control_panel.py | 323 ++++++++------- tests/components/mqtt/test_binary_sensor.py | 382 ++++++++++-------- tests/components/mqtt/test_common.py | 24 ++ 3 files changed, 410 insertions(+), 319 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index a7e5678a3b74..79c06d7a5f3f 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -35,9 +35,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -204,17 +204,12 @@ async def test_update_state_via_state_topic( assert hass.states.get(entity_id).state == state +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_ignore_update_state_if_unknown_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test ignoring updates via state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() entity_id = "alarm_control_panel.test" @@ -225,31 +220,25 @@ async def test_ignore_update_state_if_unknown_via_state_topic( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (DEFAULT_CONFIG, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (DEFAULT_CONFIG, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_no_code( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when no code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( alarm_control_panel.DOMAIN, @@ -262,31 +251,25 @@ async def test_publish_mqtt_no_code( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_CODE, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_code( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG_CODE, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -318,31 +301,29 @@ async def test_publish_mqtt_with_code( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + ( + DEFAULT_CONFIG_REMOTE_CODE, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_REMOTE_CODE, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, service, payload, ) -> None: """Test publishing of MQTT messages when remode code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG_REMOTE_CODE, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -365,31 +346,29 @@ async def test_publish_mqtt_with_remote_code( @pytest.mark.parametrize( - ("service", "payload"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS"), - (SERVICE_ALARM_DISARM, "DISARM"), - (SERVICE_ALARM_TRIGGER, "TRIGGER"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_HOME, "ARM_HOME"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_AWAY, "ARM_AWAY"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_ARM_VACATION, "ARM_VACATION"), + ( + DEFAULT_CONFIG_REMOTE_CODE_TEXT, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_DISARM, "DISARM"), + (DEFAULT_CONFIG_REMOTE_CODE_TEXT, SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) async def test_publish_mqtt_with_remote_code_text( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - service, - payload, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + service: str, + payload: str, ) -> None: """Test publishing of MQTT messages when remote text code is configured.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - DEFAULT_CONFIG_REMOTE_CODE_TEXT, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -412,38 +391,85 @@ async def test_publish_mqtt_with_remote_code_text( @pytest.mark.parametrize( - ("service", "payload", "disable_code"), + ("hass_config", "service", "payload"), [ - (SERVICE_ALARM_ARM_HOME, "ARM_HOME", "code_arm_required"), - (SERVICE_ALARM_ARM_AWAY, "ARM_AWAY", "code_arm_required"), - (SERVICE_ALARM_ARM_NIGHT, "ARM_NIGHT", "code_arm_required"), - (SERVICE_ALARM_ARM_VACATION, "ARM_VACATION", "code_arm_required"), - (SERVICE_ALARM_ARM_CUSTOM_BYPASS, "ARM_CUSTOM_BYPASS", "code_arm_required"), - (SERVICE_ALARM_DISARM, "DISARM", "code_disarm_required"), - (SERVICE_ALARM_TRIGGER, "TRIGGER", "code_trigger_required"), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_HOME, + "ARM_HOME", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_AWAY, + "ARM_AWAY", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_NIGHT, + "ARM_NIGHT", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_VACATION, + "ARM_VACATION", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_arm_required": False},), + ), + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "ARM_CUSTOM_BYPASS", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_disarm_required": False},), + ), + SERVICE_ALARM_DISARM, + "DISARM", + ), + ( + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ({"code_trigger_required": False},), + ), + SERVICE_ALARM_TRIGGER, + "TRIGGER", + ), ], ) async def test_publish_mqtt_with_code_required_false( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - service, - payload, - disable_code, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + service: str, + payload: str, ) -> None: """Test publishing of MQTT messages when code is configured. code_arm_required = False / code_disarm_required = False / code_trigger_required = False """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN][disable_code] = False - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # No code provided, should publish await hass.services.async_call( @@ -476,25 +502,29 @@ async def test_publish_mqtt_with_code_required_false( mqtt_mock.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, + ( + { + "code": "0123", + "command_template": '{"action":"{{ action }}","code":"{{ code }}"}', + }, + ), + ) + ], +) async def test_disarm_publishes_mqtt_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test publishing of MQTT messages while disarmed. When command_template set to output json """ - config = copy.deepcopy(DEFAULT_CONFIG_CODE) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = "0123" - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN][ - "command_template" - ] = '{"action":"{{ action }}","code":"{{ code }}"}' - assert await async_setup_component( - hass, - mqtt.DOMAIN, - config, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_alarm_disarm(hass, "0123") mqtt_mock.async_publish.assert_called_once_with( @@ -502,13 +532,9 @@ async def test_disarm_publishes_mqtt_with_template( ) -async def test_update_state_via_state_topic_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test updating with template_value via state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { alarm_control_panel.DOMAIN: { @@ -523,10 +549,14 @@ async def test_update_state_via_state_topic_template( {% endif %}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_update_state_via_state_topic_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test updating with template_value via state topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert state.state == STATE_UNKNOWN @@ -537,16 +567,19 @@ async def test_update_state_via_state_topic_template( assert state.state == STATE_ALARM_ARMED_AWAY +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ({"code": CODE_NUMBER},) + ) + ], +) async def test_attributes_code_number( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = copy.deepcopy(DEFAULT_CONFIG) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = CODE_NUMBER - - assert await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( @@ -555,16 +588,21 @@ async def test_attributes_code_number( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_REMOTE_CODE, + ({"code": "REMOTE_CODE"},), + ) + ], +) async def test_attributes_remote_code_number( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = copy.deepcopy(DEFAULT_CONFIG_REMOTE_CODE) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = "REMOTE_CODE" - - assert await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( @@ -573,16 +611,19 @@ async def test_attributes_remote_code_number( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ({"code": CODE_TEXT},) + ) + ], +) async def test_attributes_code_text( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = copy.deepcopy(DEFAULT_CONFIG) - config[mqtt.DOMAIN][alarm_control_panel.DOMAIN]["code"] = CODE_TEXT - - assert await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 3e224a4136a7..0d3cd6954906 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -19,10 +19,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State, callback -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -74,15 +75,9 @@ def binary_sensor_platform_only(): yield -async def test_setting_sensor_value_expires_availability_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -93,10 +88,16 @@ async def test_setting_sensor_value_expires_availability_topic( "availability_topic": "availability-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires_availability_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE @@ -110,15 +111,9 @@ async def test_setting_sensor_value_expires_availability_topic( await expires_helper(hass) -async def test_setting_sensor_value_expires( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -128,10 +123,16 @@ async def test_setting_sensor_value_expires( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("binary_sensor.test") @@ -274,13 +275,9 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( assert state.state == STATE_UNAVAILABLE -async def test_setting_sensor_value_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -290,10 +287,14 @@ async def test_setting_sensor_value_via_mqtt_message( "payload_off": "OFF", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") @@ -312,15 +313,9 @@ async def test_setting_sensor_value_via_mqtt_message( assert state.state == STATE_UNKNOWN -async def test_invalid_sensor_value_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -330,10 +325,16 @@ async def test_invalid_sensor_value_via_mqtt_message( "payload_off": "OFF", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_invalid_sensor_value_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") @@ -356,13 +357,9 @@ async def test_invalid_sensor_value_via_mqtt_message( assert "No matching payload found for entity" in caplog.text -async def test_setting_sensor_value_via_mqtt_message_and_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -374,10 +371,14 @@ async def test_setting_sensor_value_via_mqtt_message_and_template( "{%-else-%}ON{%-endif%}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message_and_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -391,15 +392,9 @@ async def test_setting_sensor_value_via_mqtt_message_and_template( assert state.state == STATE_OFF -async def test_setting_sensor_value_via_mqtt_message_and_template2( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -411,9 +406,15 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( } } }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + ], +) +async def test_setting_sensor_value_via_mqtt_message_and_template2( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -432,15 +433,9 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( assert "template output: 'ILLEGAL'" in caplog.text -async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_encoding( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test processing a raw value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -452,10 +447,16 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ "value_template": "{%if value|unpack('b')-%}ON{%else%}OFF{%-endif-%}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_encoding( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test processing a raw value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -469,13 +470,9 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ assert state.state == STATE_OFF -async def test_setting_sensor_value_via_mqtt_message_empty_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -486,10 +483,14 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( "value_template": '{%if value == "ABC"%}ON{%endif%}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message_empty_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -503,13 +504,9 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( assert state.state == STATE_ON -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of a valid sensor class.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -518,22 +515,22 @@ async def test_valid_device_class( "state_topic": "test-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid sensor class.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state.attributes.get("device_class") == "motion" -async def test_invalid_device_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test the setting of an invalid sensor class.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -542,8 +539,17 @@ async def test_invalid_device_class( "state_topic": "test-topic", } } - }, - ) + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid sensor class.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [mqtt]: expected BinarySensorDeviceClass" in caplog.text @@ -585,13 +591,9 @@ async def test_custom_availability_payload( ) -async def test_force_update_disabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -601,10 +603,14 @@ async def test_force_update_disabled( "payload_off": "OFF", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_disabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() events = [] @@ -624,13 +630,9 @@ async def test_force_update_disabled( assert len(events) == 1 -async def test_force_update_enabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -641,10 +643,14 @@ async def test_force_update_enabled( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_enabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() events = [] @@ -664,13 +670,9 @@ async def test_force_update_enabled( assert len(events) == 2 -async def test_off_delay( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test off_delay option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { binary_sensor.DOMAIN: { @@ -682,10 +684,14 @@ async def test_off_delay( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_off_delay( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test off_delay option.""" + await mqtt_mock_entry_no_yaml_config() events = [] @@ -1068,40 +1074,54 @@ async def test_reloadable( @pytest.mark.parametrize( - ("payload1", "state1", "payload2", "state2"), - [("ON", "on", "OFF", "off"), ("OFF", "off", "ON", "on")], + ("hass_config", "payload1", "state1", "payload2", "state2"), + [ + ( + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ( + {"name": "test1", "expire_after": 30, "state_topic": "test-topic1"}, + {"name": "test2", "expire_after": 5, "state_topic": "test-topic2"}, + ), + ), + "ON", + "on", + "OFF", + "off", + ), + ( + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ( + {"name": "test1", "expire_after": 30, "state_topic": "test-topic1"}, + {"name": "test2", "expire_after": 5, "state_topic": "test-topic2"}, + ), + ), + "OFF", + "off", + "ON", + "on", + ), + ], ) async def test_cleanup_triggers_and_restoring_state( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, freezer: FrozenDateTimeFactory, + hass_config: ConfigType, payload1, state1, payload2, state2, ) -> None: """Test cleanup old triggers at reloading and restoring the state.""" - domain = binary_sensor.DOMAIN - config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config1["name"] = "test1" - config1["expire_after"] = 30 - config1["state_topic"] = "test-topic1" - config2 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config2["name"] = "test2" - config2["expire_after"] = 5 - config2["state_topic"] = "test-topic2" - freezer.move_to("2022-02-02 12:01:00+01:00") - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {binary_sensor.DOMAIN: [config1, config2]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic1", payload1) state = hass.states.get("binary_sensor.test1") @@ -1114,7 +1134,7 @@ async def test_cleanup_triggers_and_restoring_state( freezer.move_to("2022-02-02 12:01:10+01:00") await help_test_reload_with_config( - hass, caplog, tmp_path, {mqtt.DOMAIN: {domain: [config1, config2]}} + hass, caplog, tmp_path, {mqtt.DOMAIN: hass_config} ) state = hass.states.get("binary_sensor.test1") @@ -1132,9 +1152,19 @@ async def test_cleanup_triggers_and_restoring_state( assert state.state == state2 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ({"name": "test3", "expire_after": 10, "state_topic": "test-topic3"},), + ) + ], +) async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" @@ -1153,11 +1183,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ) mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {domain: config3}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test3") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index f5a4648e34cd..6d238a63f433 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1,4 +1,5 @@ """Common test objects.""" +from collections.abc import Iterable from contextlib import suppress import copy from datetime import datetime @@ -119,6 +120,29 @@ async def help_setup_component( return mqtt_mock +def help_custom_config( + mqtt_entity_domain: str, + mqtt_base_config: ConfigType, + mqtt_entity_configs: Iterable[ConfigType,], +) -> ConfigType: + """Tweak a default config for parametrization. + + Returns a custom config to be used as parametrization for with hass_config, + based on the supplied mqtt_base_config and updated with mqtt_entity_configs. + For each item in mqtt_entity_configs an entity instance is added to the config. + """ + config: ConfigType = copy.deepcopy(mqtt_base_config) + entity_instances: list[ConfigType] = [] + for instance in mqtt_entity_configs: + base: ConfigType = copy.deepcopy( + mqtt_base_config[mqtt.DOMAIN][mqtt_entity_domain] + ) + base.update(instance) + entity_instances.append(base) + config[mqtt.DOMAIN][mqtt_entity_domain]: list[ConfigType] = entity_instances + return config + + async def help_test_availability_when_connection_lost( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, From 7efe058aa6d6d607f87982b9c152c02eda05728f Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 22 Mar 2023 10:46:17 +0100 Subject: [PATCH 0675/1058] Bump easyEnergy to v0.2.2 (#90080) --- 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 fc0a4fd7739c..0954269628a9 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==0.2.1"] + "requirements": ["easyenergy==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index a19f989c5841..0bfd27361c87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 639b7e2d4ded..7a02110f54b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,7 +493,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.1 +easyenergy==0.2.2 # homeassistant.components.elgato elgato==4.0.1 From 87e6dd3949872a76173e2d3e3f6ecdea27c6d263 Mon Sep 17 00:00:00 2001 From: Matrix Date: Wed, 22 Mar 2023 19:01:04 +0800 Subject: [PATCH 0676/1058] YoLink flexfob support (#90027) --- homeassistant/components/yolink/__init__.py | 39 +++- homeassistant/components/yolink/const.py | 1 + .../components/yolink/device_trigger.py | 88 +++++++++ homeassistant/components/yolink/sensor.py | 5 +- homeassistant/components/yolink/strings.json | 12 ++ .../components/yolink/test_device_trigger.py | 169 ++++++++++++++++++ 6 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/yolink/device_trigger.py create mode 100644 tests/components/yolink/test_device_trigger.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7362a09609a8..c10cc8158eae 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -7,6 +7,7 @@ from datetime import timedelta from typing import Any import async_timeout +from yolink.const import ATTR_DEVICE_SMART_REMOTER from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError from yolink.home_manager import YoLinkHome @@ -16,11 +17,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + device_registry as dr, +) from . import api -from .const import DOMAIN +from .const import DOMAIN, YOLINK_EVENT from .coordinator import YoLinkCoordinator +from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS SCAN_INTERVAL = timedelta(minutes=5) @@ -53,9 +59,32 @@ class YoLinkHomeMessageListener(MessageListener): device_coordinators = entry_data.device_coordinators if not device_coordinators: return - device_coordiantor = device_coordinators.get(device.device_id) - if device_coordiantor is not None: - device_coordiantor.async_set_updated_data(msg_data) + device_coordinator = device_coordinators.get(device.device_id) + if device_coordinator is None: + return + device_coordinator.async_set_updated_data(msg_data) + # handling events + if ( + device_coordinator.device.device_type == ATTR_DEVICE_SMART_REMOTER + and msg_data.get("event") is not None + ): + device_registry = dr.async_get(self._hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_coordinator.device.device_id)} + ) + if device_entry is None: + return + key_press_type = None + if msg_data["event"]["type"] == "Press": + key_press_type = CONF_SHORT_PRESS + else: + key_press_type = CONF_LONG_PRESS + button_idx = msg_data["event"]["keyMask"] + event_data = { + "type": f"button_{button_idx}_{key_press_type}", + "device_id": device_entry.id, + } + self._hass.bus.async_fire(YOLINK_EVENT, event_data) @dataclass diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 61cbc8b3028f..935889a0368e 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -7,3 +7,4 @@ ATTR_DEVICE_TYPE = "type" ATTR_DEVICE_NAME = "name" ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" +YOLINK_EVENT = f"{DOMAIN}_event" diff --git a/homeassistant/components/yolink/device_trigger.py b/homeassistant/components/yolink/device_trigger.py new file mode 100644 index 000000000000..aac860c6a27b --- /dev/null +++ b/homeassistant/components/yolink/device_trigger.py @@ -0,0 +1,88 @@ +"""Provides device triggers for YoLink.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from yolink.const import ATTR_DEVICE_SMART_REMOTER + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN, YOLINK_EVENT + +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" +CONF_SHORT_PRESS = "short_press" +CONF_LONG_PRESS = "long_press" + +REMOTE_TRIGGER_TYPES = { + f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_2}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_3}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_3}_{CONF_LONG_PRESS}", + f"{CONF_BUTTON_4}_{CONF_SHORT_PRESS}", + f"{CONF_BUTTON_4}_{CONF_LONG_PRESS}", +} + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): vol.In(REMOTE_TRIGGER_TYPES)} +) + + +# YoLink Remotes YS3604/YS3605/YS3606/YS3607 +DEVICE_TRIGGER_TYPES: dict[str, set[str]] = { + ATTR_DEVICE_SMART_REMOTER: REMOTE_TRIGGER_TYPES, +} + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for YoLink devices.""" + device_registry = dr.async_get(hass) + registry_device = device_registry.async_get(device_id) + if not registry_device or registry_device.model != ATTR_DEVICE_SMART_REMOTER: + return [] + + triggers = [] + for trigger in DEVICE_TRIGGER_TYPES[ATTR_DEVICE_SMART_REMOTER]: + triggers.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + } + ) + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + event_config = { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: YOLINK_EVENT, + event_trigger.CONF_EVENT_DATA: { + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + CONF_TYPE: config[CONF_TYPE], + }, + } + event_config = event_trigger.TRIGGER_SCHEMA(event_config) + return await event_trigger.async_attach_trigger( + hass, event_config, action, trigger_info, platform_type="device" + ) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 4850df4a26de..5f89f54ccbe7 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -1,4 +1,4 @@ -"""YoLink Binary Sensor.""" +"""YoLink Sensor.""" from __future__ import annotations from collections.abc import Callable @@ -15,6 +15,7 @@ from yolink.const import ( ATTR_DEVICE_MULTI_OUTLET, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SIREN, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_THERMOSTAT, @@ -68,6 +69,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_OUTLET, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SIREN, ATTR_DEVICE_SWITCH, @@ -84,6 +86,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 94fe5dc09aa5..de16e1a6e392 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -21,5 +21,17 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "device_automation": { + "trigger_type": { + "button_1_short_press": "Button_1 (short press)", + "button_1_long_press": "Button_1 (long press)", + "button_2_short_press": "Button_2 (short press)", + "button_2_long_press": "Button_2 (long press)", + "button_3_short_press": "Button_3 (short press)", + "button_3_long_press": "Button_3 (long press)", + "button_4_short_press": "Button_4 (short press)", + "button_4_long_press": "Button_4 (long press)" + } } } diff --git a/tests/components/yolink/test_device_trigger.py b/tests/components/yolink/test_device_trigger.py new file mode 100644 index 000000000000..f5679ca19c90 --- /dev/null +++ b/tests/components/yolink/test_device_trigger.py @@ -0,0 +1,169 @@ +"""The tests for YoLink device triggers.""" +import pytest +from yolink.const import ATTR_DEVICE_DIMMER, ATTR_DEVICE_SMART_REMOTER + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.yolink import DOMAIN, YOLINK_EVENT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, +) + + +@pytest.fixture +def calls(hass: HomeAssistant): + """Track calls to a mock service.""" + return async_mock_service(hass, "yolink", "automation") + + +async def test_get_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test we get the expected triggers from a yolink flexfob.""" + config_entry = MockConfigEntry(domain="yolink", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + model=ATTR_DEVICE_SMART_REMOTER, + ) + + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "button_1_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_1_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_2_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_2_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_3_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_3_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_4_short_press", + "device_id": device_entry.id, + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "button_4_long_press", + "device_id": device_entry.id, + "metadata": {}, + }, + ] + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert_lists_same(triggers, expected_triggers) + + +async def test_get_triggers_exception( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test get triggers when device type not flexfob.""" + config_entry = MockConfigEntry(domain="yolink", data={}) + config_entry.add_to_hass(hass) + device_entity = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + model=ATTR_DEVICE_DIMMER, + ) + + expected_triggers = [] + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entity.id + ) + assert_lists_same(triggers, expected_triggers) + + +async def test_if_fires_on_event( + hass: HomeAssistant, calls, device_registry: dr.DeviceRegistry +) -> None: + """Test for event triggers firing.""" + mac_address = "12:34:56:AB:CD:EF" + connection = (dr.CONNECTION_NETWORK_MAC, mac_address) + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={connection}, + identifiers={(DOMAIN, mac_address)}, + model=ATTR_DEVICE_SMART_REMOTER, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "button_1_long_press", + }, + "action": { + "service": "yolink.automation", + "data": {"message": "service called"}, + }, + }, + ] + }, + ) + + device = device_registry.async_get_device(set(), {connection}) + assert device is not None + # Fake remote button long press. + hass.bus.async_fire( + event_type=YOLINK_EVENT, + event_data={ + "type": "button_1_long_press", + "device_id": device.id, + }, + ) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["message"] == "service called" From 9b9ed21dc4b0de9bc8dccf5ec1ab047bcb084e8b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 08:24:28 -0400 Subject: [PATCH 0677/1058] Update hass-nabucasa to 0.62.0 (#90085) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/tts.py | 7 +++++-- homeassistant/components/tts/__init__.py | 3 ++- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7f8dfca14480..7bd4a822fba4 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.61.1"] + "requirements": ["hass-nabucasa==0.62.0"] } diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index 00eacf7ca528..bbf4ef287d68 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,7 +1,7 @@ """Support for the cloud for text to speech service.""" from hass_nabucasa import Cloud -from hass_nabucasa.voice import MAP_VOICE, VoiceError +from hass_nabucasa.voice import MAP_VOICE, AudioOutput, VoiceError import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider @@ -99,7 +99,10 @@ class CloudProvider(Provider): # Process TTS try: data = await self.cloud.voice.process_tts( - message, language, gender=options[CONF_GENDER] + message, + language, + gender=options[CONF_GENDER], + output=AudioOutput.MP3, ) except VoiceError: return (None, None) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 0d253d7d94f9..39aedfe8cbd3 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -502,7 +502,8 @@ class SpeechManager: ) # Save to memory - data = self.write_tags(filename, data, provider, message, language, options) + if extension == "mp3": + data = self.write_tags(filename, data, provider, message, language, options) self._async_store_to_memcache(cache_key, filename, data) if cache: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 29665559e4dd..edc88caef8a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ ciso8601==2.3.0 cryptography==39.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 -hass-nabucasa==0.61.1 +hass-nabucasa==0.62.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 diff --git a/requirements_all.txt b/requirements_all.txt index 0bfd27361c87..91b480e973a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -868,7 +868,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.1 +hass-nabucasa==0.62.0 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a02110f54b5..6b88020affdf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -666,7 +666,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.61.1 +hass-nabucasa==0.62.0 # homeassistant.components.conversation hassil==1.0.6 From 19d56a7102ce1d2f58b64cba5c6f8b60b9c0c71a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 13:32:02 +0100 Subject: [PATCH 0678/1058] Change error handling in async_process_play_media_url (#90052) --- homeassistant/components/media_player/browse_media.py | 2 +- tests/components/media_player/test_browse_media.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index d1328a851d25..2b046868f164 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -44,7 +44,7 @@ def async_process_play_media_url( return media_content_id else: if media_content_id[0] != "/": - raise ValueError("URL is relative, but does not start with a /") + return media_content_id if parsed.query: logging.getLogger(__name__).debug( diff --git a/tests/components/media_player/test_browse_media.py b/tests/components/media_player/test_browse_media.py index 014eeb7dc48c..c7ce52eb12ae 100644 --- a/tests/components/media_player/test_browse_media.py +++ b/tests/components/media_player/test_browse_media.py @@ -86,8 +86,8 @@ async def test_process_play_media_url(hass: HomeAssistant, mock_sign_path) -> No == "http://example.local:8123/api/tts_proxy/bla" ) - with pytest.raises(ValueError): - async_process_play_media_url(hass, "hello") + # Not changing a URL which is not absolute and does not start with / + async_process_play_media_url(hass, "hello") == "hello" async def test_process_play_media_url_for_addon( From 0ca67233788038d0d00dfa4c3cd4de8e707f5b85 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 08:36:36 -0400 Subject: [PATCH 0679/1058] Allow passing binary to the WS connection (#89882) * Allow passing binary to the WS connection * Expand test coverage * Test non-existing handler * Allow signaling end of stream using empty payloads * Store handlers in a list * Handle binary handlers raising exceptions --- .../components/websocket_api/connection.py | 60 ++++++++++++- .../components/websocket_api/http.py | 9 ++ .../websocket_api/test_connection.py | 24 ++++++ tests/components/websocket_api/test_http.py | 85 ++++++++++++++++++- 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 08d053145215..f91cc3a827af 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -25,6 +25,9 @@ current_connection = ContextVar["ActiveConnection | None"]( "current_connection", default=None ) +MessageHandler = Callable[[HomeAssistant, "ActiveConnection", dict[str, Any]], None] +BinaryHandler = Callable[[HomeAssistant, "ActiveConnection", bytes], None] + class ActiveConnection: """Handle an active websocket client connection.""" @@ -46,7 +49,10 @@ class ActiveConnection: self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 self.supported_features: dict[str, float] = {} - self.handlers = self.hass.data[const.DOMAIN] + self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ + const.DOMAIN + ] + self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) def get_description(self, request: web.Request | None) -> str: @@ -60,6 +66,38 @@ class ActiveConnection: """Return a context.""" return Context(user_id=self.user.id) + @callback + def async_register_binary_handler( + self, handler: BinaryHandler + ) -> tuple[int, Callable[[], None]]: + """Register a temporary binary handler for this connection. + + Returns a binary handler_id (1 byte) and a callback to unregister the handler. + """ + if len(self.binary_handlers) < 255: + index = len(self.binary_handlers) + self.binary_handlers.append(None) + else: + # Once the list is full, we search for a None entry to reuse. + index = None + for idx, existing in enumerate(self.binary_handlers): + if existing is None: + index = idx + break + + if index is None: + raise RuntimeError("Too many binary handlers registered") + + self.binary_handlers[index] = handler + + @callback + def unsub() -> None: + """Unregister the handler.""" + assert index is not None + self.binary_handlers[index] = None + + return index + 1, unsub + @callback def send_result(self, msg_id: int, result: Any | None = None) -> None: """Send a result message.""" @@ -75,6 +113,26 @@ class ActiveConnection: """Send a error message.""" self.send_message(messages.error_message(msg_id, code, message)) + @callback + def async_handle_binary(self, handler_id: int, payload: bytes) -> None: + """Handle a single incoming binary message.""" + index = handler_id - 1 + if ( + index < 0 + or index >= len(self.binary_handlers) + or (handler := self.binary_handlers[index]) is None + ): + self.logger.error( + "Received binary message for non-existing handler %s", handler_id + ) + return + + try: + handler(self.hass, self, payload) + except Exception: # pylint: disable=broad-except + self.logger.exception("Error handling binary message") + self.binary_handlers[index] = None + @callback def async_handle(self, msg: dict[str, Any]) -> None: """Handle a single incoming message.""" diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index de0b23e49572..75eccc7aba99 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -312,6 +312,15 @@ class WebSocketHandler: if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): break + if msg.type == WSMsgType.BINARY: + if len(msg.data) < 1: + disconnect_warn = "Received invalid binary message." + break + handler = msg.data[0] + payload = msg.data[1:] + connection.async_handle_binary(handler, payload) + continue + if msg.type != WSMsgType.TEXT: disconnect_warn = "Received non-Text message." break diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py index 53baab98b4ff..da435d64d588 100644 --- a/tests/components/websocket_api/test_connection.py +++ b/tests/components/websocket_api/test_connection.py @@ -101,3 +101,27 @@ async def test_exception_handling( assert send_messages[0]["error"]["code"] == code assert send_messages[0]["error"]["message"] == err assert log in caplog.text + + +async def test_binary_handler_registration() -> None: + """Test binary handler registration.""" + connection = websocket_api.ActiveConnection( + None, Mock(data={websocket_api.DOMAIN: None}), None, None, Mock() + ) + + # One filler to align indexes with prefix numbers + unsubs = [None] + fake_handler = object() + for i in range(255): + prefix, unsub = connection.async_register_binary_handler(fake_handler) + assert prefix == i + 1 + unsubs.append(unsub) + + with pytest.raises(RuntimeError): + connection.async_register_binary_handler(None) + + unsubs[15]() + + # Verify we reuse an unsubscribed prefix + prefix, unsub = connection.async_register_binary_handler(None) + assert prefix == 15 diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index fce6eb428aee..475fbeee7658 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -1,13 +1,20 @@ """Test Websocket API http module.""" import asyncio from datetime import timedelta +from typing import Any from unittest.mock import patch from aiohttp import ServerDisconnectedError, WSMsgType, web import pytest -from homeassistant.components.websocket_api import const, http -from homeassistant.core import HomeAssistant +from homeassistant.components.websocket_api import ( + async_register_command, + const, + http, + websocket_command, +) +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.core import HomeAssistant, callback from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -155,3 +162,77 @@ async def test_prepare_fail( await hass_ws_client(hass) assert "Timeout preparing request" in caplog.text + + +async def test_binary_message( + hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture +) -> None: + """Test binary messages.""" + binary_payloads = { + 104: ([], asyncio.Future()), + 105: ([], asyncio.Future()), + } + + # Register a handler + @callback + @websocket_command( + { + "type": "get_binary_message_handler", + } + ) + def get_binary_message_handler( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] + ): + unsub = None + + @callback + def binary_message_handler( + hass: HomeAssistant, connection: ActiveConnection, payload: bytes + ): + nonlocal unsub + if msg["id"] == 103: + raise ValueError("Boom") + + if payload: + binary_payloads[msg["id"]][0].append(payload) + else: + binary_payloads[msg["id"]][1].set_result( + b"".join(binary_payloads[msg["id"]][0]) + ) + unsub() + + prefix, unsub = connection.async_register_binary_handler(binary_message_handler) + + connection.send_result(msg["id"], {"prefix": prefix}) + + async_register_command(hass, get_binary_message_handler) + + # Register multiple binary handlers + for i in range(101, 106): + await websocket_client.send_json( + {"id": i, "type": "get_binary_message_handler"} + ) + result = await websocket_client.receive_json() + assert result["id"] == i + assert result["type"] == const.TYPE_RESULT + assert result["success"] + assert result["result"]["prefix"] == i - 100 + + # Send message to binary + await websocket_client.send_bytes((0).to_bytes(1, "big") + b"test0") + await websocket_client.send_bytes((3).to_bytes(1, "big") + b"test3") + await websocket_client.send_bytes((3).to_bytes(1, "big") + b"test3") + await websocket_client.send_bytes((10).to_bytes(1, "big") + b"test10") + await websocket_client.send_bytes((4).to_bytes(1, "big") + b"test4") + await websocket_client.send_bytes((4).to_bytes(1, "big") + b"") + await websocket_client.send_bytes((5).to_bytes(1, "big") + b"test5") + await websocket_client.send_bytes((5).to_bytes(1, "big") + b"test5-2") + await websocket_client.send_bytes((5).to_bytes(1, "big") + b"") + + # Verify received + assert await binary_payloads[104][1] == b"test4" + assert await binary_payloads[105][1] == b"test5test5-2" + assert "Error handling binary message" in caplog.text + assert "Received binary message for non-existing handler 0" in caplog.text + assert "Received binary message for non-existing handler 3" in caplog.text + assert "Received binary message for non-existing handler 10" in caplog.text From 0ecd043cb2f0e84d8234fce6a82529988630caa5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 13:59:35 +0100 Subject: [PATCH 0680/1058] Add test helper mock_config_flow (#90103) --- tests/common.py | 11 +++++++++++ .../application_credentials/test_init.py | 8 ++++---- tests/components/hassio/test_discovery.py | 18 +++++++++++------- .../test_silabs_multiprotocol_addon.py | 15 ++++++++++----- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/tests/common.py b/tests/common.py index 569813d221c6..f7a2c04a5f5f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -34,6 +34,7 @@ from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) from homeassistant.config import async_process_component_config +from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_CLOSE, @@ -1242,6 +1243,16 @@ async def get_system_health_info(hass: HomeAssistant, domain: str) -> dict[str, return await hass.data["system_health"][domain].info_callback(hass) +@contextmanager +def mock_config_flow(domain: str, config_flow: type[ConfigFlow]) -> None: + """Mock a config flow handler.""" + assert domain not in config_entries.HANDLERS + config_entries.HANDLERS[domain] = config_flow + _LOGGER.info("Adding mock config flow: %s", domain) + yield + config_entries.HANDLERS.pop(domain) + + def mock_integration( hass: HomeAssistant, module: MockModule, built_in: bool = True ) -> loader.Integration: diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index 2f17340b0713..cc56894cf0d1 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, mock_platform +from tests.common import MockConfigEntry, mock_config_flow, mock_platform from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -98,7 +98,7 @@ async def mock_application_credentials_integration( yield -class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN): +class FakeConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): """Config flow used during tests.""" DOMAIN = TEST_DOMAIN @@ -115,8 +115,8 @@ def config_flow_handler( ) -> Generator[FakeConfigFlow, None, None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with patch.dict(config_entries.HANDLERS, {TEST_DOMAIN: FakeConfigFlow}): - yield FakeConfigFlow + with mock_config_flow(TEST_DOMAIN, FakeConfigFlow): + yield class OAuthFixture: diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 2cb4aa206dbe..51659927dfaf 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -12,7 +12,12 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_S from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockModule, mock_entity_platform, mock_integration +from tests.common import ( + MockModule, + mock_config_flow, + mock_entity_platform, + mock_integration, +) from tests.test_util.aiohttp import AiohttpClientMocker @@ -22,15 +27,14 @@ async def mock_mqtt_fixture(hass): mock_integration(hass, MockModule(MQTT_DOMAIN)) mock_entity_platform(hass, f"config_flow.{MQTT_DOMAIN}", None) - with patch.dict(config_entries.HANDLERS): + class MqttFlow(config_entries.ConfigFlow): + """Test flow.""" - class MqttFlow(config_entries.ConfigFlow, domain=MQTT_DOMAIN): - """Test flow.""" + VERSION = 1 - VERSION = 1 - - async_step_hassio = AsyncMock(return_value={"type": "abort"}) + async_step_hassio = AsyncMock(return_value={"type": "abort"}) + with mock_config_flow(MQTT_DOMAIN, MqttFlow): yield MqttFlow diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index abe66d35a961..57e4a23ab5f7 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -7,7 +7,6 @@ from unittest.mock import Mock, patch import pytest -from homeassistant import config_entries from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN @@ -15,12 +14,18 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult, FlowResultType -from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) TEST_DOMAIN = "test" -class TestConfigFlow(ConfigFlow, domain=TEST_DOMAIN): +class TestConfigFlow(ConfigFlow): """Handle a config flow for the silabs multiprotocol add-on.""" VERSION = 1 @@ -87,8 +92,8 @@ def config_flow_handler( ) -> Generator[TestConfigFlow, None, None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with patch.dict(config_entries.HANDLERS, {TEST_DOMAIN: TestConfigFlow}): - yield TestConfigFlow + with mock_config_flow(TEST_DOMAIN, TestConfigFlow): + yield async def test_option_flow_install_multi_pan_addon( From 130c8ea5f54f73ed84e34da20683ebbd2b9d4ce0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 14:03:39 +0100 Subject: [PATCH 0681/1058] Update OTRB config entry if REST API port has changed (#90101) * Update OTRB config entry if REST API port has changed * Improve test coverage --- homeassistant/components/otbr/__init__.py | 7 ++ homeassistant/components/otbr/config_flow.py | 25 +++++-- tests/components/otbr/test_config_flow.py | 68 +++++++++++++++++++- tests/components/otbr/test_init.py | 27 +++++++- 4 files changed, 118 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 602c76f77ef9..a25ff8b46bc6 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -155,6 +155,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _warn_on_default_network_settings(hass, entry, dataset_tlvs) await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + hass.data[DOMAIN] = otbrdata return True @@ -166,6 +168,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_get_active_dataset_tlvs(hass: HomeAssistant) -> bytes | None: """Get current active operational dataset in TLVS format, or None. diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 0e9c8e960600..4247d5dbd653 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -8,10 +8,11 @@ import aiohttp import python_otbr_api from python_otbr_api import tlv_parser import voluptuous as vol +import yarl from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -86,11 +87,25 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle hassio discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - config = discovery_info.config url = f"http://{config['host']}:{config['port']}" + config_entry_data = {"url": url} + + if current_entries := self._async_current_entries(): + for current_entry in current_entries: + if current_entry.source != SOURCE_HASSIO: + continue + current_url = yarl.URL(current_entry.data["url"]) + if ( + current_url.host != config["host"] + or current_url.port == config["port"] + ): + continue + # Update URL with the new port + self.hass.config_entries.async_update_entry( + current_entry, data=config_entry_data + ) + return self.async_abort(reason="single_instance_allowed") try: await self._connect_and_create_dataset(url) @@ -101,5 +116,5 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) return self.async_create_entry( title="Open Thread Border Router", - data={"url": url}, + data=config_entry_data, ) diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 2ec79dcaeed8..ae49c63002a6 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Open Thread Border Router config flow.""" import asyncio from http import HTTPStatus +from typing import Any from unittest.mock import patch import aiohttp @@ -373,8 +374,69 @@ async def test_hassio_discovery_flow_404( assert result["reason"] == "unknown" -@pytest.mark.parametrize("source", ("hassio", "user")) -async def test_config_flow_single_entry(hass: HomeAssistant, source: str) -> None: +async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None: + """Test the port can be updated.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={ + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + }, + domain=otbr.DOMAIN, + options={}, + source="hassio", + title="Open Thread Border Router", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + expected_data = { + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", + } + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + + +async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) -> None: + """Test the port is not updated if we get data for another addon hosting OTBR.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}"}, + domain=otbr.DOMAIN, + options={}, + source="hassio", + title="Open Thread Border Router", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + # Make sure the data was not updated + expected_data = { + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}", + } + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + + +@pytest.mark.parametrize(("source", "data"), [("hassio", HASSIO_DATA), ("user", None)]) +async def test_config_flow_single_entry( + hass: HomeAssistant, source: str, data: Any +) -> None: """Test only a single entry is allowed.""" mock_integration(hass, MockModule("hassio")) @@ -392,7 +454,7 @@ async def test_config_flow_single_entry(hass: HomeAssistant, source: str) -> Non return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - otbr.DOMAIN, context={"source": source} + otbr.DOMAIN, context={"source": source}, data=data ) assert result["type"] == FlowResultType.ABORT diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 2b329ae8d99b..3ed3ec8c30a5 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,7 +1,7 @@ """Test the Open Thread Border Router integration.""" import asyncio from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import aiohttp import pytest @@ -100,6 +100,31 @@ async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: assert not await hass.config_entries.async_setup(config_entry.entry_id) +async def test_config_entry_update(hass: HomeAssistant) -> None: + """Test update config entry settings.""" + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + ) + config_entry.add_to_hass(hass) + mock_api = MagicMock() + mock_api.get_active_dataset_tlvs = AsyncMock(return_value=None) + 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) + + mock_otrb_api.assert_called_once_with(CONFIG_ENTRY_DATA["url"], ANY, ANY) + + new_config_entry_data = {"url": "http://core-silabs-multiprotocol:8082"} + assert CONFIG_ENTRY_DATA["url"] != new_config_entry_data["url"] + with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: + hass.config_entries.async_update_entry(config_entry, data=new_config_entry_data) + await hass.async_block_till_done() + + mock_otrb_api.assert_called_once_with(new_config_entry_data["url"], ANY, ANY) + + async def test_remove_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry ) -> None: From c581116c824b008c36ef0e05c6d098450f63bb04 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 22 Mar 2023 11:15:46 -0400 Subject: [PATCH 0682/1058] ZHA network settings API (#88564) * Rename `zha.api` to `zha.websocket_api` * Implement a ZHA network settings API * Use the enum name as the radio type * Don't filter out ignored config entries * [WIP] Start unit tests * Add unit tests * Rename ZHA websocket API module in `.coveragerc` * Rename `api` to `websocket_api` * Increase test coverage to 100% --- .coveragerc | 2 +- homeassistant/components/zha/__init__.py | 8 +- homeassistant/components/zha/api.py | 1615 +---------------- homeassistant/components/zha/core/device.py | 2 +- homeassistant/components/zha/core/gateway.py | 22 +- homeassistant/components/zha/device_action.py | 2 +- homeassistant/components/zha/websocket_api.py | 1541 ++++++++++++++++ tests/components/zha/test_api.py | 869 +-------- tests/components/zha/test_init.py | 4 +- tests/components/zha/test_websocket_api.py | 842 +++++++++ 10 files changed, 2558 insertions(+), 2349 deletions(-) create mode 100644 homeassistant/components/zha/websocket_api.py create mode 100644 tests/components/zha/test_websocket_api.py diff --git a/.coveragerc b/.coveragerc index 20ee077ffa0b..e59c60ddccb3 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1508,7 +1508,7 @@ omit = homeassistant/components/zeversolar/coordinator.py homeassistant/components/zeversolar/entity.py homeassistant/components/zeversolar/sensor.py - homeassistant/components/zha/api.py + homeassistant/components/zha/websocket_api.py homeassistant/components/zha/core/channels/* homeassistant/components/zha/core/device.py homeassistant/components/zha/core/gateway.py diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index dd07d4da4280..5607cabffea1 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType -from . import api +from . import websocket_api from .core import ZHAGateway from .core.const import ( BAUD_RATES, @@ -131,7 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b model=zha_gateway.radio_description, ) - api.async_load_api(hass) + websocket_api.async_load_api(hass) async def async_zha_shutdown(event): """Handle shutdown tasks.""" @@ -150,11 +150,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA].pop(DATA_ZHA_GATEWAY) await zha_gateway.shutdown() GROUP_PROBE.cleanup() - api.async_unload_api(hass) + websocket_api.async_unload_api(hass) # our components don't have unload methods so no need to look at return values await asyncio.gather( diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d0e04e0c1628..d34dd2338e34 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1,1549 +1,120 @@ -"""Web socket API for Zigbee Home Automation devices.""" +"""API for Zigbee Home Automation.""" + from __future__ import annotations -import asyncio -import logging -from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast +from typing import TYPE_CHECKING -import voluptuous as vol -import zigpy.backups from zigpy.backups import NetworkBackup -from zigpy.config.validators import cv_boolean -from zigpy.types.named import EUI64 -from zigpy.zcl.clusters.security import IasAce -import zigpy.zdo.types as zdo_types - -from homeassistant.components import websocket_api -from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME -from homeassistant.core import HomeAssistant, ServiceCall, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.service import async_register_admin_service +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from .core.const import ( - ATTR_ARGS, - ATTR_ATTRIBUTE, - ATTR_CLUSTER_ID, - ATTR_CLUSTER_TYPE, - ATTR_COMMAND_TYPE, - ATTR_ENDPOINT_ID, - ATTR_IEEE, - ATTR_LEVEL, - ATTR_MANUFACTURER, - ATTR_MEMBERS, - ATTR_PARAMS, - ATTR_TYPE, - ATTR_VALUE, - ATTR_WARNING_DEVICE_DURATION, - ATTR_WARNING_DEVICE_MODE, - ATTR_WARNING_DEVICE_STROBE, - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, - ATTR_WARNING_DEVICE_STROBE_INTENSITY, - BINDINGS, - CHANNEL_IAS_WD, - CLUSTER_COMMAND_SERVER, - CLUSTER_COMMANDS_CLIENT, - CLUSTER_COMMANDS_SERVER, - CLUSTER_TYPE_IN, - CLUSTER_TYPE_OUT, CONF_RADIO_TYPE, - CUSTOM_CONFIGURATION, DATA_ZHA, + DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY, DOMAIN, - EZSP_OVERWRITE_EUI64, - GROUP_ID, - GROUP_IDS, - GROUP_NAME, - MFG_CLUSTER_ID_START, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_SOUND_HIGH, - WARNING_DEVICE_SQUAWK_MODE_ARMED, - WARNING_DEVICE_STROBE_HIGH, - WARNING_DEVICE_STROBE_YES, - ZHA_ALARM_OPTIONS, - ZHA_CHANNEL_MSG, - ZHA_CONFIG_SCHEMAS, -) -from .core.gateway import EntityReference -from .core.group import GroupMember -from .core.helpers import ( - async_cluster_exists, - async_is_bindable_target, - cluster_command_schema_to_vol_schema, - convert_install_code, - get_matched_clusters, - qr_to_install_code, + RadioType, ) +from .core.gateway import ZHAGateway if TYPE_CHECKING: - from homeassistant.components.websocket_api.connection import ActiveConnection + from zigpy.application import ControllerApplication - from .core.device import ZHADevice - from .core.gateway import ZHAGateway - -_LOGGER = logging.getLogger(__name__) - -TYPE = "type" -CLIENT = "client" -ID = "id" -RESPONSE = "response" -DEVICE_INFO = "device_info" - -ATTR_DURATION = "duration" -ATTR_GROUP = "group" -ATTR_IEEE_ADDRESS = "ieee_address" -ATTR_INSTALL_CODE = "install_code" -ATTR_SOURCE_IEEE = "source_ieee" -ATTR_TARGET_IEEE = "target_ieee" -ATTR_QR_CODE = "qr_code" - -SERVICE_PERMIT = "permit" -SERVICE_REMOVE = "remove" -SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" -SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" -SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" -SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" -SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" -SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" -SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" -SERVICE_ZIGBEE_BIND = "service_zigbee_bind" -IEEE_SERVICE = "ieee_based_service" - -IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) - -# typing typevar -_T = TypeVar("_T") + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant -def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: - """Wrap value in list if it is provided and not one.""" - if value is None: +def _get_gateway(hass: HomeAssistant) -> ZHAGateway: + """Get a reference to the ZHA gateway device.""" + return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + +def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Find the singleton ZHA config entry, if one exists.""" + + # If ZHA is already running, use its config entry + try: + zha_gateway = _get_gateway(hass) + except KeyError: + pass + else: + return zha_gateway.config_entry + + # Otherwise, find one + entries = hass.config_entries.async_entries(DOMAIN) + + if len(entries) != 1: + raise ValueError(f"Invalid number of ZHA config entries: {entries!r}") + + return entries[0] + + +def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup: + """Wrap the ZHA network settings into a `NetworkBackup`.""" + return NetworkBackup( + node_info=app.state.node_info, + network_info=app.state.network_info, + ) + + +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + + return _wrap_network_settings(zha_gateway.application_controller) + + +async def async_get_last_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get the network settings for the last-active ZHA network.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + + config = hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + zha_gateway = ZHAGateway(hass, config, config_entry) + + app_controller_cls, app_config = zha_gateway.get_application_controller_data() + app = app_controller_cls(app_config) + + try: + await app._load_db() # pylint: disable=protected-access + settings = _wrap_network_settings(app) + finally: + await app.shutdown() + + if settings.network_info.channel == 0: return None - return cast("list[_T]", value) if isinstance(value, list) else [value] + + return settings -SERVICE_PERMIT_PARAMS = { - vol.Optional(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional(ATTR_DURATION, default=60): vol.All( - vol.Coerce(int), vol.Range(0, 254) - ), - vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, - vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( - cv.string, convert_install_code - ), - vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), -} - -SERVICE_SCHEMAS = { - SERVICE_PERMIT: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - SERVICE_PERMIT_PARAMS, - ) - ), - IEEE_SERVICE: vol.Schema( - vol.All( - cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), - {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, - ) - ), - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - 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, - } - ), - SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - } - ), - SERVICE_WARNING_DEVICE_WARN: vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Optional( - ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES - ): cv.positive_int, - vol.Optional( - ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH - ): cv.positive_int, - vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 - ): cv.positive_int, - vol.Optional( - ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH - ): cv.positive_int, - } - ), - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, - vol.Required(ATTR_COMMAND): cv.positive_int, - 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, - } - ), - cv.deprecated(ATTR_ARGS), - cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), - ), - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( - { - vol.Required(ATTR_GROUP): cv.positive_int, - vol.Required(ATTR_CLUSTER_ID): cv.positive_int, - 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, - } - ), -} - - -class ClusterBinding(NamedTuple): - """Describes a cluster binding.""" - - name: str - type: str - id: int - endpoint_id: int - - -def _cv_group_member(value: dict[str, Any]) -> GroupMember: - """Transform a group member.""" - return GroupMember( - ieee=value[ATTR_IEEE], - endpoint_id=value[ATTR_ENDPOINT_ID], - ) - - -def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: - """Transform a cluster binding.""" - return ClusterBinding( - name=value[ATTR_NAME], - type=value[ATTR_TYPE], - id=value[ATTR_ID], - endpoint_id=value[ATTR_ENDPOINT_ID], - ) - - -def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: - """Transform a zigpy network backup.""" +async def async_get_network_settings( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> NetworkBackup | None: + """Get ZHA network settings, preferring the active settings if ZHA is running.""" try: - return zigpy.backups.NetworkBackup.from_dict(value) - except ValueError as err: - raise vol.Invalid(str(err)) from err + return async_get_active_network_settings(hass) + except KeyError: + return await async_get_last_network_settings(hass, config_entry) -GROUP_MEMBER_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_group_member, -) +def async_get_radio_type( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> RadioType: + """Get ZHA radio type.""" + if config_entry is None: + config_entry = _get_config_entry(hass) + return RadioType[config_entry.data[CONF_RADIO_TYPE]] -CLUSTER_BINDING_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(ATTR_NAME): cv.string, - vol.Required(ATTR_TYPE): cv.string, - vol.Required(ATTR_ID): vol.Coerce(int), - vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), - } - ), - _cv_cluster_binding, -) +def async_get_radio_path( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> str: + """Get ZHA radio path.""" + if config_entry is None: + config_entry = _get_config_entry(hass) -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required("type"): "zha/devices/permit", - **SERVICE_PERMIT_PARAMS, - } -) -@websocket_api.async_response -async def websocket_permit_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Permit ZHA zigbee devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - duration: int = msg[ATTR_DURATION] - ieee: EUI64 | None = msg.get(ATTR_IEEE) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, "zha_gateway_message", forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener and turn off debug mode.""" - zha_gateway.async_disable_debug_mode() - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - zha_gateway.async_enable_debug_mode() - src_ieee: EUI64 - code: bytes - 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 - ) - 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 - ) - else: - await zha_gateway.application_controller.permit(time_s=duration, node=ieee) - connection.send_result(msg[ID]) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) -@websocket_api.async_response -async def websocket_get_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - devices = [device.zha_device_info for device in zha_gateway.devices.values()] - connection.send_result(msg[ID], devices) - - -@callback -def _get_entity_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.name if entry else None - - -@callback -def _get_entity_original_name( - zha_gateway: ZHAGateway, entity_ref: EntityReference -) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) - return entry.original_name if entry else None - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) -@websocket_api.async_response -async def websocket_get_groupable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices that can be grouped.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - devices = [device for device in zha_gateway.devices.values() if device.is_groupable] - groupable_devices = [] - - for device in devices: - entity_refs = zha_gateway.device_registry[device.ieee] - for ep_id in device.async_get_groupable_endpoints(): - groupable_devices.append( - { - "endpoint_id": ep_id, - "entities": [ - { - "name": _get_entity_name(zha_gateway, entity_ref), - "original_name": _get_entity_original_name( - zha_gateway, entity_ref - ), - } - for entity_ref in entity_refs - if list(entity_ref.cluster_channels.values())[ - 0 - ].cluster.endpoint.endpoint_id - == ep_id - ], - "device": device.zha_device_info, - } - ) - - connection.send_result(msg[ID], groupable_devices) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) -@websocket_api.async_response -async def websocket_get_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/device", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_device( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - - if not (zha_device := zha_gateway.devices.get(ieee)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" - ) - ) - return - - device_info = zha_device.zha_device_info - connection.send_result(msg[ID], device_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group", - vol.Required(GROUP_ID): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_get_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - group_info = zha_group.group_info - connection.send_result(msg[ID], group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/add", - vol.Required(GROUP_NAME): cv.string, - vol.Optional(GROUP_ID): cv.positive_int, - vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add a new ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_name: str = msg[GROUP_NAME] - group_id: int | None = msg.get(GROUP_ID) - members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) - group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) - assert group - connection.send_result(msg[ID], group.group_info) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/remove", - vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), - } -) -@websocket_api.async_response -async def websocket_remove_groups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove the specified ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_ids: list[int] = msg[GROUP_IDS] - - if len(group_ids) > 1: - tasks = [] - for group_id in group_ids: - tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) - await asyncio.gather(*tasks) - else: - await zha_gateway.async_remove_zigpy_group(group_ids[0]) - ret_groups = [group.group_info for group in zha_gateway.groups.values()] - connection.send_result(msg[ID], ret_groups) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/add", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_add_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Add members to a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_add_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/group/members/remove", - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_remove_group_members( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove members from a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id: int = msg[GROUP_ID] - members: list[GroupMember] = msg[ATTR_MEMBERS] - - if not (zha_group := zha_gateway.groups.get(group_id)): - connection.send_message( - websocket_api.error_message( - msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" - ) - ) - return - - await zha_group.async_remove_members(members) - ret_group = zha_group.group_info - connection.send_result(msg[ID], ret_group) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/reconfigure", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_reconfigure_node( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Reconfigure a ZHA nodes entities by its ieee address.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - device: ZHADevice | None = zha_gateway.get_device(ieee) - - async def forward_messages(data): - """Forward events to websocket.""" - connection.send_message(websocket_api.event_message(msg["id"], data)) - - remove_dispatcher_function = async_dispatcher_connect( - hass, ZHA_CHANNEL_MSG, forward_messages - ) - - @callback - def async_cleanup() -> None: - """Remove signal listener.""" - remove_dispatcher_function() - - connection.subscriptions[msg["id"]] = async_cleanup - - _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) - assert device - hass.async_create_task(device.async_configure()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/topology/update", - } -) -@websocket_api.async_response -async def websocket_update_topology( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA network topology.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - hass.async_create_task(zha_gateway.application_controller.topology.scan()) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_device_clusters( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of device clusters.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - zha_device = zha_gateway.get_device(ieee) - response_clusters = [] - if zha_device is not None: - clusters_by_endpoint = zha_device.async_get_clusters() - for ep_id, clusters in clusters_by_endpoint.items(): - for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_IN, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): - response_clusters.append( - { - TYPE: CLUSTER_TYPE_OUT, - ID: c_id, - ATTR_NAME: cluster.__class__.__name__, - "endpoint_id": ep_id, - } - ) - - connection.send_result(msg[ID], response_clusters) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster attributes.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - cluster_attributes: list[dict[str, Any]] = [] - zha_device = zha_gateway.get_device(ieee) - attributes = None - if zha_device is not None: - attributes = zha_device.async_get_cluster_attributes( - endpoint_id, cluster_id, cluster_type - ) - if attributes is not None: - for attr_id, attr in attributes.items(): - cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) - _LOGGER.debug( - "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_attributes, - ) - - connection.send_result(msg[ID], cluster_attributes) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/commands", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - } -) -@websocket_api.async_response -async def websocket_device_cluster_commands( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Return a list of cluster commands.""" - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - zha_device = zha_gateway.get_device(ieee) - cluster_commands: list[dict[str, Any]] = [] - commands = None - if zha_device is not None: - commands = zha_device.async_get_cluster_commands( - endpoint_id, cluster_id, cluster_type - ) - - if commands is not None: - for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): - cluster_commands.append( - { - TYPE: CLIENT, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): - cluster_commands.append( - { - TYPE: CLUSTER_COMMAND_SERVER, - ID: cmd_id, - ATTR_NAME: cmd.name, - "schema": voluptuous_serialize.convert( - cluster_command_schema_to_vol_schema(cmd.schema), - custom_serializer=cv.custom_serializer, - ), - } - ) - _LOGGER.debug( - "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - RESPONSE, - cluster_commands, - ) - - connection.send_result(msg[ID], cluster_commands) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/clusters/attributes/value", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - vol.Required(ATTR_ATTRIBUTE): int, - vol.Optional(ATTR_MANUFACTURER): cv.positive_int, - } -) -@websocket_api.async_response -async def websocket_read_zigbee_cluster_attributes( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Read zigbee attribute for cluster on ZHA entity.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = msg[ATTR_IEEE] - endpoint_id: int = msg[ATTR_ENDPOINT_ID] - cluster_id: int = msg[ATTR_CLUSTER_ID] - cluster_type: str = msg[ATTR_CLUSTER_TYPE] - attribute: int = msg[ATTR_ATTRIBUTE] - manufacturer: int | None = msg.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - 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 - ) - success, failure = await cluster.read_attributes( - [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer - ) - _LOGGER.debug( - ( - "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s]," - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - str(success.get(attribute)), - "failure", - failure, - ) - connection.send_result(msg[ID], str(success.get(attribute))) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bindable", - vol.Required(ATTR_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_get_bindable_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_IEEE] - source_device = zha_gateway.get_device(source_ieee) - - devices = [ - device.zha_device_info - for device in zha_gateway.devices.values() - if async_is_bindable_target(source_device, device) - ] - - _LOGGER.debug( - "Get bindable devices: %s: [%s], %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - "bindable devices", - devices, - ) - - connection.send_message(websocket_api.result_message(msg[ID], devices)) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_bind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req - ) - _LOGGER.info( - "Devices bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/devices/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, - } -) -@websocket_api.async_response -async def websocket_unbind_devices( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Remove a direct binding between devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req - ) - _LOGGER.info( - "Devices un-bound: %s: [%s] %s: [%s]", - ATTR_SOURCE_IEEE, - source_ieee, - ATTR_TARGET_IEEE, - target_ieee, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/bind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_bind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Directly bind a device to a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_bind_to_group(group_id, bindings) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/groups/unbind", - vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, - vol.Required(GROUP_ID): cv.positive_int, - vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), - } -) -@websocket_api.async_response -async def websocket_unbind_group( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Unbind a device from a group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] - group_id: int = msg[GROUP_ID] - bindings: list[ClusterBinding] = msg[BINDINGS] - source_device = zha_gateway.get_device(source_ieee) - assert source_device - await source_device.async_unbind_from_group(group_id, bindings) - - -async def async_binding_operation( - zha_gateway: ZHAGateway, - source_ieee: EUI64, - target_ieee: EUI64, - operation: zdo_types.ZDOCmd, -) -> None: - """Create or remove a direct zigbee binding between 2 devices.""" - - source_device = zha_gateway.get_device(source_ieee) - target_device = zha_gateway.get_device(target_ieee) - - assert source_device - assert target_device - clusters_to_bind = await get_matched_clusters(source_device, target_device) - - zdo = source_device.device.zdo - bind_tasks = [] - for binding_pair in clusters_to_bind: - op_msg = "cluster: %s %s --> [%s]" - op_params = ( - binding_pair.source_cluster.cluster_id, - operation.name, - target_ieee, - ) - zdo.debug(f"processing {op_msg}", *op_params) - - bind_tasks.append( - ( - zdo.request( - operation, - source_device.ieee, - binding_pair.source_cluster.endpoint.endpoint_id, - binding_pair.source_cluster.cluster_id, - binding_pair.destination_address, - ), - op_msg, - op_params, - ) - ) - res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) - for outcome, log_msg in zip(res, bind_tasks): - if isinstance(outcome, Exception): - fmt = f"{log_msg[1]} failed: %s" - else: - fmt = f"{log_msg[1]} completed: %s" - zdo.debug(fmt, *(log_msg[2] + (outcome,))) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) -@websocket_api.async_response -async def websocket_get_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - import voluptuous_serialize # pylint: disable=import-outside-toplevel - - def custom_serializer(schema: Any) -> Any: - """Serialize additional types for voluptuous_serialize.""" - if schema is cv_boolean: - return {"type": "bool"} - if schema is vol.Schema: - return voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - - return cv.custom_serializer(schema) - - data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( - hass, IasAce.cluster_id - ): - continue - data["schemas"][section] = voluptuous_serialize.convert( - schema, custom_serializer=custom_serializer - ) - data["data"][section] = zha_gateway.config_entry.options.get( - CUSTOM_CONFIGURATION, {} - ).get(section, {}) - - # send default values for unconfigured options - for entry in data["schemas"][section]: - if data["data"][section].get(entry["name"]) is None: - data["data"][section][entry["name"]] = entry["default"] - - connection.send_result(msg[ID], data) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/configuration/update", - vol.Required("data"): ZHA_CONFIG_SCHEMAS, - } -) -@websocket_api.async_response -async def websocket_update_zha_configuration( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Update the ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - options = zha_gateway.config_entry.options - data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} - - for section, schema in ZHA_CONFIG_SCHEMAS.items(): - for entry in schema.schema: - # remove options that match defaults - if ( - data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) - == entry.default() - ): - data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) - # remove entire section block if empty - if ( - not data_to_save[CUSTOM_CONFIGURATION].get(section) - and section in data_to_save[CUSTOM_CONFIGURATION] - ): - data_to_save[CUSTOM_CONFIGURATION].pop(section) - - # remove entire custom_configuration block if empty - if ( - not data_to_save.get(CUSTOM_CONFIGURATION) - and CUSTOM_CONFIGURATION in data_to_save - ): - data_to_save.pop(CUSTOM_CONFIGURATION) - - _LOGGER.info( - "Updating ZHA custom configuration options from %s to %s", - options, - data_to_save, - ) - - hass.config_entries.async_update_entry( - zha_gateway.config_entry, options=data_to_save - ) - status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id) - connection.send_result(msg[ID], status) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) -@websocket_api.async_response -async def websocket_get_network_settings( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize the current network settings - backup = NetworkBackup( - node_info=application_controller.state.node_info, - network_info=application_controller.state.network_info, - ) - - connection.send_result( - msg[ID], - { - "radio_type": zha_gateway.config_entry.data[CONF_RADIO_TYPE], - "settings": backup.as_dict(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) -@websocket_api.async_response -async def websocket_list_network_backups( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # Serialize known backups - connection.send_result( - msg[ID], [backup.as_dict() for backup in application_controller.backups] - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) -@websocket_api.async_response -async def websocket_create_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Create a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - # This can take 5-30s - backup = await application_controller.backups.create_backup(load_devices=True) - connection.send_result( - msg[ID], - { - "backup": backup.as_dict(), - "is_complete": backup.is_complete(), - }, - ) - - -@websocket_api.require_admin -@websocket_api.websocket_command( - { - vol.Required(TYPE): "zha/network/backups/restore", - vol.Required("backup"): _cv_zigpy_network_backup, - vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, - } -) -@websocket_api.async_response -async def websocket_restore_network_backup( - hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] -) -> None: - """Restore a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - backup = msg["backup"] - - if msg["ezsp_force_write_eui64"]: - backup.network_info.stack_specific.setdefault("ezsp", {})[ - EZSP_OVERWRITE_EUI64 - ] = True - - # This can take 30-40s - try: - await application_controller.backups.restore_backup(backup) - except ValueError as err: - connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) - else: - connection.send_result(msg[ID]) - - -@callback -def async_load_api(hass: HomeAssistant) -> None: - """Set up the web socket API.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - application_controller = zha_gateway.application_controller - - async def permit(service: ServiceCall) -> None: - """Allow devices to join this network.""" - duration: int = service.data[ATTR_DURATION] - ieee: EUI64 | None = service.data.get(ATTR_IEEE) - src_ieee: EUI64 - code: bytes - 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 - ) - 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 - ) - return - - if ieee: - _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) - else: - _LOGGER.info("Permitting joins for %ss", duration) - await application_controller.permit(time_s=duration, node=ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] - ) - - async def remove(service: ServiceCall) -> None: - """Remove a node from the network.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee: EUI64 = service.data[ATTR_IEEE] - zha_device: ZHADevice | None = zha_gateway.get_device(ieee) - if zha_device is not None and zha_device.is_active_coordinator: - _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) - return - _LOGGER.info("Removing node %s", ieee) - await application_controller.remove(ieee) - - async_register_admin_service( - hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] - ) - - async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: - """Set zigbee attribute for cluster on zha entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - attribute: int | str = service.data[ATTR_ATTRIBUTE] - value: int | bool | str = service.data[ATTR_VALUE] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - 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, - attribute, - value, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" - " [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_ATTRIBUTE, - attribute, - ATTR_VALUE, - value, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, - set_zigbee_cluster_attributes, - schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], - ) - - async def issue_zigbee_cluster_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on ZHA entity.""" - ieee: EUI64 = service.data[ATTR_IEEE] - endpoint_id: int = service.data[ATTR_ENDPOINT_ID] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - cluster_type: str = service.data[ATTR_CLUSTER_TYPE] - command: int = service.data[ATTR_COMMAND] - command_type: str = service.data[ATTR_COMMAND_TYPE] - args: list | None = service.data.get(ATTR_ARGS) - params: dict | None = service.data.get(ATTR_PARAMS) - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - zha_device = zha_gateway.get_device(ieee) - if zha_device is not None: - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - manufacturer = zha_device.manufacturer_code - - await zha_device.issue_cluster_command( - endpoint_id, - cluster_id, - command, - command_type, - args, - params, - cluster_type=cluster_type, - manufacturer=manufacturer, - ) - _LOGGER.debug( - ( - "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" - " %s: [%s] %s: [%s] %s: [%s]" - ), - ATTR_CLUSTER_ID, - cluster_id, - ATTR_CLUSTER_TYPE, - cluster_type, - ATTR_ENDPOINT_ID, - endpoint_id, - ATTR_COMMAND, - command, - ATTR_COMMAND_TYPE, - command_type, - ATTR_ARGS, - args, - ATTR_PARAMS, - params, - ATTR_MANUFACTURER, - manufacturer, - ) - else: - raise ValueError(f"Device with IEEE {str(ieee)} not found") - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, - issue_zigbee_cluster_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], - ) - - async def issue_zigbee_group_command(service: ServiceCall) -> None: - """Issue command on zigbee cluster on a zigbee group.""" - group_id: int = service.data[ATTR_GROUP] - cluster_id: int = service.data[ATTR_CLUSTER_ID] - command: int = service.data[ATTR_COMMAND] - args: list = service.data[ATTR_ARGS] - manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) - group = zha_gateway.get_group(group_id) - if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: - _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) - response = None - if group is not None: - cluster = group.endpoint[cluster_id] - response = await cluster.command( - command, *args, manufacturer=manufacturer, expect_reply=True - ) - _LOGGER.debug( - "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", - ATTR_CLUSTER_ID, - cluster_id, - ATTR_COMMAND, - command, - ATTR_ARGS, - args, - ATTR_MANUFACTURER, - manufacturer, - RESPONSE, - response, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, - issue_zigbee_group_command, - schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], - ) - - def _get_ias_wd_channel(zha_device): - """Get the IASWD channel for a device.""" - cluster_channels = { - ch.name: ch - for pool in zha_device.channels.pools - for ch in pool.claimed_channels.values() - } - return cluster_channels.get(CHANNEL_IAS_WD) - - async def warning_device_squawk(service: ServiceCall) -> None: - """Issue the squawk command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_squawk(mode, strobe, level) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_SQUAWK, - warning_device_squawk, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], - ) - - async def warning_device_warn(service: ServiceCall) -> None: - """Issue the warning command for an IAS warning device.""" - ieee: EUI64 = service.data[ATTR_IEEE] - mode: int = service.data[ATTR_WARNING_DEVICE_MODE] - strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] - level: int = service.data[ATTR_LEVEL] - duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] - duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] - intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] - - if (zha_device := zha_gateway.get_device(ieee)) is not None: - if channel := _get_ias_wd_channel(zha_device): - await channel.issue_start_warning( - mode, strobe, level, duration, duty_mode, intensity - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] is missing the required IASWD channel!", - ATTR_IEEE, - str(ieee), - ) - else: - _LOGGER.error( - "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) - ) - _LOGGER.debug( - "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", - ATTR_IEEE, - str(ieee), - ATTR_WARNING_DEVICE_MODE, - mode, - ATTR_WARNING_DEVICE_STROBE, - strobe, - ATTR_LEVEL, - level, - ) - - async_register_admin_service( - hass, - DOMAIN, - SERVICE_WARNING_DEVICE_WARN, - warning_device_warn, - schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], - ) - - websocket_api.async_register_command(hass, websocket_permit_devices) - websocket_api.async_register_command(hass, websocket_get_devices) - websocket_api.async_register_command(hass, websocket_get_groupable_devices) - websocket_api.async_register_command(hass, websocket_get_groups) - websocket_api.async_register_command(hass, websocket_get_device) - websocket_api.async_register_command(hass, websocket_get_group) - websocket_api.async_register_command(hass, websocket_add_group) - websocket_api.async_register_command(hass, websocket_remove_groups) - websocket_api.async_register_command(hass, websocket_add_group_members) - websocket_api.async_register_command(hass, websocket_remove_group_members) - websocket_api.async_register_command(hass, websocket_bind_group) - websocket_api.async_register_command(hass, websocket_unbind_group) - websocket_api.async_register_command(hass, websocket_reconfigure_node) - websocket_api.async_register_command(hass, websocket_device_clusters) - websocket_api.async_register_command(hass, websocket_device_cluster_attributes) - websocket_api.async_register_command(hass, websocket_device_cluster_commands) - websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) - websocket_api.async_register_command(hass, websocket_get_bindable_devices) - websocket_api.async_register_command(hass, websocket_bind_devices) - websocket_api.async_register_command(hass, websocket_unbind_devices) - websocket_api.async_register_command(hass, websocket_update_topology) - websocket_api.async_register_command(hass, websocket_get_configuration) - websocket_api.async_register_command(hass, websocket_update_zha_configuration) - websocket_api.async_register_command(hass, websocket_get_network_settings) - websocket_api.async_register_command(hass, websocket_list_network_backups) - websocket_api.async_register_command(hass, websocket_create_network_backup) - websocket_api.async_register_command(hass, websocket_restore_network_backup) - - -@callback -def async_unload_api(hass: HomeAssistant) -> None: - """Unload the ZHA API.""" - hass.services.async_remove(DOMAIN, SERVICE_PERMIT) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE) - hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) - hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) + return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 17ec04fa9e86..9d40314e0611 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -84,7 +84,7 @@ from .const import ( from .helpers import LogMixin, async_get_zha_config_value, convert_to_zcl_values if TYPE_CHECKING: - from ..api import ClusterBinding + from ..websocket_api import ClusterBinding from .gateway import ZHAGateway _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 1bc77d3f3608..3f9ada1ed084 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -148,14 +148,8 @@ class ZHAGateway: self._unsubs: list[Callable[[], None]] = [] self.initialized: bool = False - async def async_initialize(self) -> None: - """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - + def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: + """Get an uninitialized instance of a zigpy `ControllerApplication`.""" radio_type = self.config_entry.data[CONF_RADIO_TYPE] app_controller_cls = RadioType[radio_type].controller @@ -178,7 +172,17 @@ class ZHAGateway: ): app_config[CONF_USE_THREAD] = False - app_config = app_controller_cls.SCHEMA(app_config) + return app_controller_cls, app_controller_cls.SCHEMA(app_config) + + async def async_initialize(self) -> None: + """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) + + app_controller_cls, app_config = self.get_application_controller_data() for attempt in range(STARTUP_RETRIES): try: diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 9867bc5cfbb9..25a01f45baaf 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -12,10 +12,10 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -from .api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN from .core.channels.manufacturerspecific import AllLEDEffectType, SingleLEDEffectType from .core.const import CHANNEL_IAS_WD, CHANNEL_INOVELLI from .core.helpers import async_get_zha_device +from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN # mypy: disallow-any-generics diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py new file mode 100644 index 000000000000..d2da6af01264 --- /dev/null +++ b/homeassistant/components/zha/websocket_api.py @@ -0,0 +1,1541 @@ +"""Web socket API for Zigbee Home Automation devices.""" +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast + +import voluptuous as vol +import zigpy.backups +from zigpy.config.validators import cv_boolean +from zigpy.types.named import EUI64 +from zigpy.zcl.clusters.security import IasAce +import zigpy.zdo.types as zdo_types + +from homeassistant.components import websocket_api +from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, ServiceCall, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.service import async_register_admin_service + +from .api import async_get_active_network_settings, async_get_radio_type +from .core.const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE, + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_COMMAND_TYPE, + ATTR_ENDPOINT_ID, + ATTR_IEEE, + ATTR_LEVEL, + ATTR_MANUFACTURER, + ATTR_MEMBERS, + ATTR_PARAMS, + ATTR_TYPE, + ATTR_VALUE, + ATTR_WARNING_DEVICE_DURATION, + ATTR_WARNING_DEVICE_MODE, + ATTR_WARNING_DEVICE_STROBE, + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + BINDINGS, + CHANNEL_IAS_WD, + CLUSTER_COMMAND_SERVER, + CLUSTER_COMMANDS_CLIENT, + CLUSTER_COMMANDS_SERVER, + CLUSTER_TYPE_IN, + CLUSTER_TYPE_OUT, + CUSTOM_CONFIGURATION, + DATA_ZHA, + DATA_ZHA_GATEWAY, + DOMAIN, + EZSP_OVERWRITE_EUI64, + GROUP_ID, + GROUP_IDS, + GROUP_NAME, + MFG_CLUSTER_ID_START, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_SQUAWK_MODE_ARMED, + WARNING_DEVICE_STROBE_HIGH, + WARNING_DEVICE_STROBE_YES, + ZHA_ALARM_OPTIONS, + ZHA_CHANNEL_MSG, + ZHA_CONFIG_SCHEMAS, +) +from .core.gateway import EntityReference +from .core.group import GroupMember +from .core.helpers import ( + async_cluster_exists, + async_is_bindable_target, + cluster_command_schema_to_vol_schema, + convert_install_code, + get_matched_clusters, + qr_to_install_code, +) + +if TYPE_CHECKING: + from homeassistant.components.websocket_api.connection import ActiveConnection + + from .core.device import ZHADevice + from .core.gateway import ZHAGateway + +_LOGGER = logging.getLogger(__name__) + +TYPE = "type" +CLIENT = "client" +ID = "id" +RESPONSE = "response" +DEVICE_INFO = "device_info" + +ATTR_DURATION = "duration" +ATTR_GROUP = "group" +ATTR_IEEE_ADDRESS = "ieee_address" +ATTR_INSTALL_CODE = "install_code" +ATTR_SOURCE_IEEE = "source_ieee" +ATTR_TARGET_IEEE = "target_ieee" +ATTR_QR_CODE = "qr_code" + +SERVICE_PERMIT = "permit" +SERVICE_REMOVE = "remove" +SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = "set_zigbee_cluster_attribute" +SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" +SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND = "issue_zigbee_group_command" +SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" +SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" +SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" +SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" +SERVICE_ZIGBEE_BIND = "service_zigbee_bind" +IEEE_SERVICE = "ieee_based_service" + +IEEE_SCHEMA = vol.All(cv.string, EUI64.convert) + +# typing typevar +_T = TypeVar("_T") + + +def _ensure_list_if_present(value: _T | None) -> list[_T] | list[Any] | None: + """Wrap value in list if it is provided and not one.""" + if value is None: + return None + return cast("list[_T]", value) if isinstance(value, list) else [value] + + +SERVICE_PERMIT_PARAMS = { + vol.Optional(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional(ATTR_DURATION, default=60): vol.All( + vol.Coerce(int), vol.Range(0, 254) + ), + vol.Inclusive(ATTR_SOURCE_IEEE, "install_code"): IEEE_SCHEMA, + vol.Inclusive(ATTR_INSTALL_CODE, "install_code"): vol.All( + cv.string, convert_install_code + ), + vol.Exclusive(ATTR_QR_CODE, "install_code"): vol.All(cv.string, qr_to_install_code), +} + +SERVICE_SCHEMAS = { + SERVICE_PERMIT: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + SERVICE_PERMIT_PARAMS, + ) + ), + IEEE_SERVICE: vol.Schema( + vol.All( + cv.deprecated(ATTR_IEEE_ADDRESS, replacement_key=ATTR_IEEE), + {vol.Required(ATTR_IEEE): IEEE_SCHEMA}, + ) + ), + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + 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, + } + ), + SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_WARN: vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH + ): cv.positive_int, + } + ), + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + vol.Optional(ATTR_CLUSTER_TYPE, default=CLUSTER_TYPE_IN): cv.string, + vol.Required(ATTR_COMMAND): cv.positive_int, + 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, + } + ), + cv.deprecated(ATTR_ARGS), + cv.has_at_least_one_key(ATTR_ARGS, ATTR_PARAMS), + ), + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND: vol.Schema( + { + vol.Required(ATTR_GROUP): cv.positive_int, + vol.Required(ATTR_CLUSTER_ID): cv.positive_int, + 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, + } + ), +} + + +class ClusterBinding(NamedTuple): + """Describes a cluster binding.""" + + name: str + type: str + id: int + endpoint_id: int + + +def _cv_group_member(value: dict[str, Any]) -> GroupMember: + """Transform a group member.""" + return GroupMember( + ieee=value[ATTR_IEEE], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_cluster_binding(value: dict[str, Any]) -> ClusterBinding: + """Transform a cluster binding.""" + return ClusterBinding( + name=value[ATTR_NAME], + type=value[ATTR_TYPE], + id=value[ATTR_ID], + endpoint_id=value[ATTR_ENDPOINT_ID], + ) + + +def _cv_zigpy_network_backup(value: dict[str, Any]) -> zigpy.backups.NetworkBackup: + """Transform a zigpy network backup.""" + + try: + return zigpy.backups.NetworkBackup.from_dict(value) + except ValueError as err: + raise vol.Invalid(str(err)) from err + + +GROUP_MEMBER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_group_member, +) + + +CLUSTER_BINDING_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_NAME): cv.string, + vol.Required(ATTR_TYPE): cv.string, + vol.Required(ATTR_ID): vol.Coerce(int), + vol.Required(ATTR_ENDPOINT_ID): vol.Coerce(int), + } + ), + _cv_cluster_binding, +) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "zha/devices/permit", + **SERVICE_PERMIT_PARAMS, + } +) +@websocket_api.async_response +async def websocket_permit_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Permit ZHA zigbee devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + duration: int = msg[ATTR_DURATION] + ieee: EUI64 | None = msg.get(ATTR_IEEE) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, "zha_gateway_message", forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener and turn off debug mode.""" + zha_gateway.async_disable_debug_mode() + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + zha_gateway.async_enable_debug_mode() + src_ieee: EUI64 + code: bytes + 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 + ) + 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 + ) + else: + await zha_gateway.application_controller.permit(time_s=duration, node=ieee) + connection.send_result(msg[ID]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices"}) +@websocket_api.async_response +async def websocket_get_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + devices = [device.zha_device_info for device in zha_gateway.devices.values()] + connection.send_result(msg[ID], devices) + + +@callback +def _get_entity_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.name if entry else None + + +@callback +def _get_entity_original_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.original_name if entry else None + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) +@websocket_api.async_response +async def websocket_get_groupable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices that can be grouped.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + devices = [device for device in zha_gateway.devices.values() if device.is_groupable] + groupable_devices = [] + + for device in devices: + entity_refs = zha_gateway.device_registry[device.ieee] + for ep_id in device.async_get_groupable_endpoints(): + groupable_devices.append( + { + "endpoint_id": ep_id, + "entities": [ + { + "name": _get_entity_name(zha_gateway, entity_ref), + "original_name": _get_entity_original_name( + zha_gateway, entity_ref + ), + } + for entity_ref in entity_refs + if list(entity_ref.cluster_channels.values())[ + 0 + ].cluster.endpoint.endpoint_id + == ep_id + ], + "device": device.zha_device_info, + } + ) + + connection.send_result(msg[ID], groupable_devices) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/groups"}) +@websocket_api.async_response +async def websocket_get_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA groups.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/device", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_device( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + + if not (zha_device := zha_gateway.devices.get(ieee)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Device not found" + ) + ) + return + + device_info = zha_device.zha_device_info + connection.send_result(msg[ID], device_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group", + vol.Required(GROUP_ID): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_get_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + group_info = zha_group.group_info + connection.send_result(msg[ID], group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/add", + vol.Required(GROUP_NAME): cv.string, + vol.Optional(GROUP_ID): cv.positive_int, + vol.Optional(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add a new ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_name: str = msg[GROUP_NAME] + group_id: int | None = msg.get(GROUP_ID) + members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) + group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) + assert group + connection.send_result(msg[ID], group.group_info) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/remove", + vol.Required(GROUP_IDS): vol.All(cv.ensure_list, [cv.positive_int]), + } +) +@websocket_api.async_response +async def websocket_remove_groups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove the specified ZHA groups.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_ids: list[int] = msg[GROUP_IDS] + + if len(group_ids) > 1: + tasks = [] + for group_id in group_ids: + tasks.append(zha_gateway.async_remove_zigpy_group(group_id)) + await asyncio.gather(*tasks) + else: + await zha_gateway.async_remove_zigpy_group(group_ids[0]) + ret_groups = [group.group_info for group in zha_gateway.groups.values()] + connection.send_result(msg[ID], ret_groups) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/add", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_add_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Add members to a ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_add_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/group/members/remove", + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(ATTR_MEMBERS): vol.All(cv.ensure_list, [GROUP_MEMBER_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_remove_group_members( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove members from a ZHA group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] + + if not (zha_group := zha_gateway.groups.get(group_id)): + connection.send_message( + websocket_api.error_message( + msg[ID], websocket_api.const.ERR_NOT_FOUND, "ZHA Group not found" + ) + ) + return + + await zha_group.async_remove_members(members) + ret_group = zha_group.group_info + connection.send_result(msg[ID], ret_group) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/reconfigure", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_reconfigure_node( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Reconfigure a ZHA nodes entities by its ieee address.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + device: ZHADevice | None = zha_gateway.get_device(ieee) + + async def forward_messages(data): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg["id"], data)) + + remove_dispatcher_function = async_dispatcher_connect( + hass, ZHA_CHANNEL_MSG, forward_messages + ) + + @callback + def async_cleanup() -> None: + """Remove signal listener.""" + remove_dispatcher_function() + + connection.subscriptions[msg["id"]] = async_cleanup + + _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + assert device + hass.async_create_task(device.async_configure()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/topology/update", + } +) +@websocket_api.async_response +async def websocket_update_topology( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA network topology.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + hass.async_create_task(zha_gateway.application_controller.topology.scan()) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_device_clusters( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of device clusters.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + zha_device = zha_gateway.get_device(ieee) + response_clusters = [] + if zha_device is not None: + clusters_by_endpoint = zha_device.async_get_clusters() + for ep_id, clusters in clusters_by_endpoint.items(): + for c_id, cluster in clusters[CLUSTER_TYPE_IN].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_IN, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + for c_id, cluster in clusters[CLUSTER_TYPE_OUT].items(): + response_clusters.append( + { + TYPE: CLUSTER_TYPE_OUT, + ID: c_id, + ATTR_NAME: cluster.__class__.__name__, + "endpoint_id": ep_id, + } + ) + + connection.send_result(msg[ID], response_clusters) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster attributes.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + cluster_attributes: list[dict[str, Any]] = [] + zha_device = zha_gateway.get_device(ieee) + attributes = None + if zha_device is not None: + attributes = zha_device.async_get_cluster_attributes( + endpoint_id, cluster_id, cluster_type + ) + if attributes is not None: + for attr_id, attr in attributes.items(): + cluster_attributes.append({ID: attr_id, ATTR_NAME: attr.name}) + _LOGGER.debug( + "Requested attributes for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_attributes, + ) + + connection.send_result(msg[ID], cluster_attributes) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/commands", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + } +) +@websocket_api.async_response +async def websocket_device_cluster_commands( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return a list of cluster commands.""" + import voluptuous_serialize # pylint: disable=import-outside-toplevel + + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + zha_device = zha_gateway.get_device(ieee) + cluster_commands: list[dict[str, Any]] = [] + commands = None + if zha_device is not None: + commands = zha_device.async_get_cluster_commands( + endpoint_id, cluster_id, cluster_type + ) + + if commands is not None: + for cmd_id, cmd in commands[CLUSTER_COMMANDS_CLIENT].items(): + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + for cmd_id, cmd in commands[CLUSTER_COMMANDS_SERVER].items(): + cluster_commands.append( + { + TYPE: CLUSTER_COMMAND_SERVER, + ID: cmd_id, + ATTR_NAME: cmd.name, + "schema": voluptuous_serialize.convert( + cluster_command_schema_to_vol_schema(cmd.schema), + custom_serializer=cv.custom_serializer, + ), + } + ) + _LOGGER.debug( + "Requested commands for: %s: %s, %s: '%s', %s: %s, %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + RESPONSE, + cluster_commands, + ) + + connection.send_result(msg[ID], cluster_commands) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/clusters/attributes/value", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + vol.Required(ATTR_ATTRIBUTE): int, + vol.Optional(ATTR_MANUFACTURER): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_read_zigbee_cluster_attributes( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Read zigbee attribute for cluster on ZHA entity.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + attribute: int = msg[ATTR_ATTRIBUTE] + manufacturer: int | None = msg.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + 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 + ) + success, failure = await cluster.read_attributes( + [attribute], allow_cache=False, only_cache=False, manufacturer=manufacturer + ) + _LOGGER.debug( + ( + "Read attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s]," + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + str(success.get(attribute)), + "failure", + failure, + ) + connection.send_result(msg[ID], str(success.get(attribute))) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bindable", + vol.Required(ATTR_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_get_bindable_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_IEEE] + source_device = zha_gateway.get_device(source_ieee) + + devices = [ + device.zha_device_info + for device in zha_gateway.devices.values() + if async_is_bindable_target(source_device, device) + ] + + _LOGGER.debug( + "Get bindable devices: %s: [%s], %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + "bindable devices", + devices, + ) + + connection.send_message(websocket_api.result_message(msg[ID], devices)) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_bind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req + ) + _LOGGER.info( + "Devices bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/devices/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(ATTR_TARGET_IEEE): IEEE_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_unbind_devices( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Remove a direct binding between devices.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req + ) + _LOGGER.info( + "Devices un-bound: %s: [%s] %s: [%s]", + ATTR_SOURCE_IEEE, + source_ieee, + ATTR_TARGET_IEEE, + target_ieee, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/bind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_bind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Directly bind a device to a group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_bind_to_group(group_id, bindings) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/groups/unbind", + vol.Required(ATTR_SOURCE_IEEE): IEEE_SCHEMA, + vol.Required(GROUP_ID): cv.positive_int, + vol.Required(BINDINGS): vol.All(cv.ensure_list, [CLUSTER_BINDING_SCHEMA]), + } +) +@websocket_api.async_response +async def websocket_unbind_group( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Unbind a device from a group.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] + source_device = zha_gateway.get_device(source_ieee) + assert source_device + await source_device.async_unbind_from_group(group_id, bindings) + + +async def async_binding_operation( + zha_gateway: ZHAGateway, + source_ieee: EUI64, + target_ieee: EUI64, + operation: zdo_types.ZDOCmd, +) -> None: + """Create or remove a direct zigbee binding between 2 devices.""" + + source_device = zha_gateway.get_device(source_ieee) + target_device = zha_gateway.get_device(target_ieee) + + assert source_device + assert target_device + clusters_to_bind = await get_matched_clusters(source_device, target_device) + + zdo = source_device.device.zdo + bind_tasks = [] + for binding_pair in clusters_to_bind: + op_msg = "cluster: %s %s --> [%s]" + op_params = ( + binding_pair.source_cluster.cluster_id, + operation.name, + target_ieee, + ) + zdo.debug(f"processing {op_msg}", *op_params) + + bind_tasks.append( + ( + zdo.request( + operation, + source_device.ieee, + binding_pair.source_cluster.endpoint.endpoint_id, + binding_pair.source_cluster.cluster_id, + binding_pair.destination_address, + ), + op_msg, + op_params, + ) + ) + res = await asyncio.gather(*(t[0] for t in bind_tasks), return_exceptions=True) + for outcome, log_msg in zip(res, bind_tasks): + if isinstance(outcome, Exception): + fmt = f"{log_msg[1]} failed: %s" + else: + fmt = f"{log_msg[1]} completed: %s" + zdo.debug(fmt, *(log_msg[2] + (outcome,))) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/configuration"}) +@websocket_api.async_response +async def websocket_get_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA configuration.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + import voluptuous_serialize # pylint: disable=import-outside-toplevel + + def custom_serializer(schema: Any) -> Any: + """Serialize additional types for voluptuous_serialize.""" + if schema is cv_boolean: + return {"type": "bool"} + if schema is vol.Schema: + return voluptuous_serialize.convert( + schema, custom_serializer=custom_serializer + ) + + return cv.custom_serializer(schema) + + data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( + hass, IasAce.cluster_id + ): + continue + data["schemas"][section] = voluptuous_serialize.convert( + schema, custom_serializer=custom_serializer + ) + data["data"][section] = zha_gateway.config_entry.options.get( + CUSTOM_CONFIGURATION, {} + ).get(section, {}) + + # send default values for unconfigured options + for entry in data["schemas"][section]: + if data["data"][section].get(entry["name"]) is None: + data["data"][section][entry["name"]] = entry["default"] + + connection.send_result(msg[ID], data) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/configuration/update", + vol.Required("data"): ZHA_CONFIG_SCHEMAS, + } +) +@websocket_api.async_response +async def websocket_update_zha_configuration( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Update the ZHA configuration.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + options = zha_gateway.config_entry.options + data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} + + for section, schema in ZHA_CONFIG_SCHEMAS.items(): + for entry in schema.schema: + # remove options that match defaults + if ( + data_to_save[CUSTOM_CONFIGURATION].get(section, {}).get(entry) + == entry.default() + ): + data_to_save[CUSTOM_CONFIGURATION][section].pop(entry) + # remove entire section block if empty + if ( + not data_to_save[CUSTOM_CONFIGURATION].get(section) + and section in data_to_save[CUSTOM_CONFIGURATION] + ): + data_to_save[CUSTOM_CONFIGURATION].pop(section) + + # remove entire custom_configuration block if empty + if ( + not data_to_save.get(CUSTOM_CONFIGURATION) + and CUSTOM_CONFIGURATION in data_to_save + ): + data_to_save.pop(CUSTOM_CONFIGURATION) + + _LOGGER.info( + "Updating ZHA custom configuration options from %s to %s", + options, + data_to_save, + ) + + hass.config_entries.async_update_entry( + zha_gateway.config_entry, options=data_to_save + ) + status = await hass.config_entries.async_reload(zha_gateway.config_entry.entry_id) + connection.send_result(msg[ID], status) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/settings"}) +@websocket_api.async_response +async def websocket_get_network_settings( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + backup = async_get_active_network_settings(hass) + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + connection.send_result( + msg[ID], + { + "radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name, + "settings": backup.as_dict(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/list"}) +@websocket_api.async_response +async def websocket_list_network_backups( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Get ZHA network settings.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # Serialize known backups + connection.send_result( + msg[ID], [backup.as_dict() for backup in application_controller.backups] + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required(TYPE): "zha/network/backups/create"}) +@websocket_api.async_response +async def websocket_create_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Create a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + # This can take 5-30s + backup = await application_controller.backups.create_backup(load_devices=True) + connection.send_result( + msg[ID], + { + "backup": backup.as_dict(), + "is_complete": backup.is_complete(), + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zha/network/backups/restore", + vol.Required("backup"): _cv_zigpy_network_backup, + vol.Optional("ezsp_force_write_eui64", default=False): cv.boolean, + } +) +@websocket_api.async_response +async def websocket_restore_network_backup( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Restore a ZHA network backup.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + backup = msg["backup"] + + if msg["ezsp_force_write_eui64"]: + backup.network_info.stack_specific.setdefault("ezsp", {})[ + EZSP_OVERWRITE_EUI64 + ] = True + + # This can take 30-40s + try: + await application_controller.backups.restore_backup(backup) + except ValueError as err: + connection.send_error(msg[ID], websocket_api.const.ERR_INVALID_FORMAT, str(err)) + else: + connection.send_result(msg[ID]) + + +@callback +def async_load_api(hass: HomeAssistant) -> None: + """Set up the web socket API.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + application_controller = zha_gateway.application_controller + + async def permit(service: ServiceCall) -> None: + """Allow devices to join this network.""" + duration: int = service.data[ATTR_DURATION] + ieee: EUI64 | None = service.data.get(ATTR_IEEE) + src_ieee: EUI64 + code: bytes + 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 + ) + 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 + ) + return + + if ieee: + _LOGGER.info("Permitting joins for %ss on %s device", duration, ieee) + else: + _LOGGER.info("Permitting joins for %ss", duration) + await application_controller.permit(time_s=duration, node=ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT] + ) + + async def remove(service: ServiceCall) -> None: + """Remove a node from the network.""" + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = service.data[ATTR_IEEE] + zha_device: ZHADevice | None = zha_gateway.get_device(ieee) + if zha_device is not None and zha_device.is_active_coordinator: + _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) + return + _LOGGER.info("Removing node %s", ieee) + await application_controller.remove(ieee) + + async_register_admin_service( + hass, DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE] + ) + + async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: + """Set zigbee attribute for cluster on zha entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + attribute: int | str = service.data[ATTR_ATTRIBUTE] + value: int | bool | str = service.data[ATTR_VALUE] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + 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, + attribute, + value, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" + " [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_ATTRIBUTE, + attribute, + ATTR_VALUE, + value, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE, + set_zigbee_cluster_attributes, + schema=SERVICE_SCHEMAS[SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE], + ) + + async def issue_zigbee_cluster_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on ZHA entity.""" + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + command: int = service.data[ATTR_COMMAND] + command_type: str = service.data[ATTR_COMMAND_TYPE] + args: list | None = service.data.get(ATTR_ARGS) + params: dict | None = service.data.get(ATTR_PARAMS) + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + zha_device = zha_gateway.get_device(ieee) + if zha_device is not None: + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + manufacturer = zha_device.manufacturer_code + + await zha_device.issue_cluster_command( + endpoint_id, + cluster_id, + command, + command_type, + args, + params, + cluster_type=cluster_type, + manufacturer=manufacturer, + ) + _LOGGER.debug( + ( + "Issued command for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s]" + " %s: [%s] %s: [%s] %s: [%s]" + ), + ATTR_CLUSTER_ID, + cluster_id, + ATTR_CLUSTER_TYPE, + cluster_type, + ATTR_ENDPOINT_ID, + endpoint_id, + ATTR_COMMAND, + command, + ATTR_COMMAND_TYPE, + command_type, + ATTR_ARGS, + args, + ATTR_PARAMS, + params, + ATTR_MANUFACTURER, + manufacturer, + ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND, + issue_zigbee_cluster_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], + ) + + async def issue_zigbee_group_command(service: ServiceCall) -> None: + """Issue command on zigbee cluster on a zigbee group.""" + group_id: int = service.data[ATTR_GROUP] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + command: int = service.data[ATTR_COMMAND] + args: list = service.data[ATTR_ARGS] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) + group = zha_gateway.get_group(group_id) + if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: + _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) + response = None + if group is not None: + cluster = group.endpoint[cluster_id] + response = await cluster.command( + command, *args, manufacturer=manufacturer, expect_reply=True + ) + _LOGGER.debug( + "Issued group command for: %s: [%s] %s: [%s] %s: %s %s: [%s] %s: %s", + ATTR_CLUSTER_ID, + cluster_id, + ATTR_COMMAND, + command, + ATTR_ARGS, + args, + ATTR_MANUFACTURER, + manufacturer, + RESPONSE, + response, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND, + issue_zigbee_group_command, + schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], + ) + + def _get_ias_wd_channel(zha_device): + """Get the IASWD channel for a device.""" + cluster_channels = { + ch.name: ch + for pool in zha_device.channels.pools + for ch in pool.claimed_channels.values() + } + return cluster_channels.get(CHANNEL_IAS_WD) + + async def warning_device_squawk(service: ServiceCall) -> None: + """Issue the squawk command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if channel := _get_ias_wd_channel(zha_device): + await channel.issue_squawk(mode, strobe, level) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_SQUAWK, + warning_device_squawk, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], + ) + + async def warning_device_warn(service: ServiceCall) -> None: + """Issue the warning command for an IAS warning device.""" + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] + duration: int = service.data[ATTR_WARNING_DEVICE_DURATION] + duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE] + intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY] + + if (zha_device := zha_gateway.get_device(ieee)) is not None: + if channel := _get_ias_wd_channel(zha_device): + await channel.issue_start_warning( + mode, strobe, level, duration, duty_mode, intensity + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] is missing the required IASWD channel!", + ATTR_IEEE, + str(ieee), + ) + else: + _LOGGER.error( + "Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee) + ) + _LOGGER.debug( + "Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]", + ATTR_IEEE, + str(ieee), + ATTR_WARNING_DEVICE_MODE, + mode, + ATTR_WARNING_DEVICE_STROBE, + strobe, + ATTR_LEVEL, + level, + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_WARNING_DEVICE_WARN, + warning_device_warn, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], + ) + + websocket_api.async_register_command(hass, websocket_permit_devices) + websocket_api.async_register_command(hass, websocket_get_devices) + websocket_api.async_register_command(hass, websocket_get_groupable_devices) + websocket_api.async_register_command(hass, websocket_get_groups) + websocket_api.async_register_command(hass, websocket_get_device) + websocket_api.async_register_command(hass, websocket_get_group) + websocket_api.async_register_command(hass, websocket_add_group) + websocket_api.async_register_command(hass, websocket_remove_groups) + websocket_api.async_register_command(hass, websocket_add_group_members) + websocket_api.async_register_command(hass, websocket_remove_group_members) + websocket_api.async_register_command(hass, websocket_bind_group) + websocket_api.async_register_command(hass, websocket_unbind_group) + websocket_api.async_register_command(hass, websocket_reconfigure_node) + websocket_api.async_register_command(hass, websocket_device_clusters) + websocket_api.async_register_command(hass, websocket_device_cluster_attributes) + websocket_api.async_register_command(hass, websocket_device_cluster_commands) + websocket_api.async_register_command(hass, websocket_read_zigbee_cluster_attributes) + websocket_api.async_register_command(hass, websocket_get_bindable_devices) + websocket_api.async_register_command(hass, websocket_bind_devices) + websocket_api.async_register_command(hass, websocket_unbind_devices) + websocket_api.async_register_command(hass, websocket_update_topology) + websocket_api.async_register_command(hass, websocket_get_configuration) + websocket_api.async_register_command(hass, websocket_update_zha_configuration) + websocket_api.async_register_command(hass, websocket_get_network_settings) + websocket_api.async_register_command(hass, websocket_list_network_backups) + websocket_api.async_register_command(hass, websocket_create_network_backup) + websocket_api.async_register_command(hass, websocket_restore_network_backup) + + +@callback +def async_unload_api(hass: HomeAssistant) -> None: + """Unload the ZHA API.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 8610c8cd7c7c..0d03b62bf878 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -1,842 +1,91 @@ """Test ZHA API.""" -from binascii import unhexlify -from copy import deepcopy -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest -import voluptuous as vol -import zigpy.backups -import zigpy.profiles.zha -import zigpy.types -import zigpy.zcl.clusters.general as general -import zigpy.zcl.clusters.security as security +import zigpy.state -from homeassistant.components.websocket_api import const -from homeassistant.components.zha import DOMAIN -from homeassistant.components.zha.api import ( - ATTR_DURATION, - ATTR_INSTALL_CODE, - ATTR_QR_CODE, - ATTR_SOURCE_IEEE, - ID, - SERVICE_PERMIT, - TYPE, - async_load_api, -) -from homeassistant.components.zha.core.const import ( - ATTR_CLUSTER_ID, - ATTR_CLUSTER_TYPE, - ATTR_ENDPOINT_ID, - ATTR_ENDPOINT_NAMES, - ATTR_IEEE, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NEIGHBORS, - ATTR_QUIRK_APPLIED, - CLUSTER_TYPE_IN, - DATA_ZHA, - DATA_ZHA_GATEWAY, - EZSP_OVERWRITE_EUI64, - GROUP_ID, - GROUP_IDS, - GROUP_NAME, -) -from homeassistant.const import ATTR_NAME, Platform -from homeassistant.core import Context, HomeAssistant - -from .conftest import ( - FIXTURE_GRP_ID, - FIXTURE_GRP_NAME, - SIG_EP_INPUT, - SIG_EP_OUTPUT, - SIG_EP_PROFILE, - SIG_EP_TYPE, -) -from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS - -from tests.common import MockUser - -IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" -IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +from homeassistant.components import zha +from homeassistant.components.zha import api +from homeassistant.components.zha.core.const import RadioType @pytest.fixture(autouse=True) def required_platform_only(): """Only set up the required and required base platforms to speed up tests.""" - with patch( - "homeassistant.components.zha.PLATFORMS", - ( - Platform.ALARM_CONTROL_PANEL, - Platform.SELECT, - Platform.SENSOR, - Platform.SWITCH, - ), - ): + with patch("homeassistant.components.zha.PLATFORMS", ()): yield -@pytest.fixture -async def device_switch(hass, zigpy_device_mock, zha_device_joined): - """Test ZHA switch platform.""" +async def test_async_get_network_settings_active(hass, setup_zha): + """Test reading settings with an active ZHA installation.""" + await setup_zha() - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - }, - ieee=IEEE_SWITCH_DEVICE, - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device + settings = await api.async_get_network_settings(hass) + assert settings.network_info.channel == 15 -@pytest.fixture -async def device_ias_ace(hass, zigpy_device_mock, zha_device_joined): - """Test alarm control panel device.""" +async def test_async_get_network_settings_inactive( + hass, setup_zha, zigpy_app_controller +): + """Test reading settings with an inactive ZHA installation.""" + await setup_zha() - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [security.IasAce.cluster_id], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - }, - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device + gateway = api._get_gateway(hass) + await zha.async_unload_entry(hass, gateway.config_entry) - -@pytest.fixture -async def device_groupable(hass, zigpy_device_mock, zha_device_joined): - """Test ZHA light platform.""" - - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [ - general.OnOff.cluster_id, - general.Basic.cluster_id, - general.Groups.cluster_id, - ], - SIG_EP_OUTPUT: [], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - }, - ieee=IEEE_GROUPABLE_DEVICE, - ) - zha_device = await zha_device_joined(zigpy_device) - zha_device.available = True - return zha_device - - -@pytest.fixture -async def zha_client(hass, hass_ws_client, device_switch, device_groupable): - """Get ZHA WebSocket client.""" - - # load the ZHA API - async_load_api(hass) - return await hass_ws_client(hass) - - -async def test_device_clusters(hass: HomeAssistant, zha_client) -> None: - """Test getting device cluster info.""" - await zha_client.send_json( - {ID: 5, TYPE: "zha/devices/clusters", ATTR_IEEE: IEEE_SWITCH_DEVICE} - ) - - msg = await zha_client.receive_json() - - assert len(msg["result"]) == 2 - - cluster_infos = sorted(msg["result"], key=lambda k: k[ID]) - - cluster_info = cluster_infos[0] - assert cluster_info[TYPE] == CLUSTER_TYPE_IN - assert cluster_info[ID] == 0 - assert cluster_info[ATTR_NAME] == "Basic" - - cluster_info = cluster_infos[1] - assert cluster_info[TYPE] == CLUSTER_TYPE_IN - assert cluster_info[ID] == 6 - assert cluster_info[ATTR_NAME] == "OnOff" - - -async def test_device_cluster_attributes(zha_client) -> None: - """Test getting device cluster attributes.""" - await zha_client.send_json( - { - ID: 5, - TYPE: "zha/devices/clusters/attributes", - ATTR_ENDPOINT_ID: 1, - ATTR_IEEE: IEEE_SWITCH_DEVICE, - ATTR_CLUSTER_ID: 6, - ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, - } - ) - - msg = await zha_client.receive_json() - - attributes = msg["result"] - assert len(attributes) == 7 - - for attribute in attributes: - assert attribute[ID] is not None - assert attribute[ATTR_NAME] is not None - - -async def test_device_cluster_commands(zha_client) -> None: - """Test getting device cluster commands.""" - await zha_client.send_json( - { - ID: 5, - TYPE: "zha/devices/clusters/commands", - ATTR_ENDPOINT_ID: 1, - ATTR_IEEE: IEEE_SWITCH_DEVICE, - ATTR_CLUSTER_ID: 6, - ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, - } - ) - - msg = await zha_client.receive_json() - - commands = msg["result"] - assert len(commands) == 6 - - for command in commands: - assert command[ID] is not None - assert command[ATTR_NAME] is not None - assert command[TYPE] is not None - - -async def test_list_devices(zha_client) -> None: - """Test getting ZHA devices.""" - await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) - - msg = await zha_client.receive_json() - - devices = msg["result"] - assert len(devices) == 2 - - msg_id = 100 - for device in devices: - msg_id += 1 - assert device[ATTR_IEEE] is not None - assert device[ATTR_MANUFACTURER] is not None - assert device[ATTR_MODEL] is not None - assert device[ATTR_NAME] is not None - assert device[ATTR_QUIRK_APPLIED] is not None - assert device["entities"] is not None - assert device[ATTR_NEIGHBORS] is not None - assert device[ATTR_ENDPOINT_NAMES] is not None - - for entity_reference in device["entities"]: - assert entity_reference[ATTR_NAME] is not None - assert entity_reference["entity_id"] is not None - - await zha_client.send_json( - {ID: msg_id, TYPE: "zha/device", ATTR_IEEE: device[ATTR_IEEE]} - ) - msg = await zha_client.receive_json() - device2 = msg["result"] - assert device == device2 - - -async def test_get_zha_config(zha_client) -> None: - """Test getting ZHA custom configuration.""" - await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) - - msg = await zha_client.receive_json() - - configuration = msg["result"] - assert configuration == BASE_CUSTOM_CONFIGURATION - - -async def test_get_zha_config_with_alarm( - hass: HomeAssistant, zha_client, device_ias_ace -) -> None: - """Test getting ZHA custom configuration.""" - await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) - - msg = await zha_client.receive_json() - - configuration = msg["result"] - assert configuration == CONFIG_WITH_ALARM_OPTIONS - - # test that the alarm options are not in the config when we remove the device - device_ias_ace.gateway.device_removed(device_ias_ace.device) - await hass.async_block_till_done() - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - - msg = await zha_client.receive_json() - - configuration = msg["result"] - assert configuration == BASE_CUSTOM_CONFIGURATION - - -async def test_update_zha_config(zha_client, zigpy_app_controller) -> None: - """Test updating ZHA custom configuration.""" - - configuration = deepcopy(CONFIG_WITH_ALARM_OPTIONS) - configuration["data"]["zha_options"]["default_light_transition"] = 10 + zigpy_app_controller.state.network_info.channel = 20 with patch( - "bellows.zigbee.application.ControllerApplication.new", + "bellows.zigbee.application.ControllerApplication.__new__", return_value=zigpy_app_controller, ): - await zha_client.send_json( - {ID: 5, TYPE: "zha/configuration/update", "data": configuration["data"]} - ) - msg = await zha_client.receive_json() - assert msg["success"] + settings = await api.async_get_network_settings(hass) - await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) - msg = await zha_client.receive_json() - configuration = msg["result"] - assert configuration == configuration + assert len(zigpy_app_controller._load_db.mock_calls) == 1 + assert len(zigpy_app_controller.start_network.mock_calls) == 0 + + assert settings.network_info.channel == 20 -async def test_device_not_found(zha_client) -> None: - """Test not found response from get device API.""" - await zha_client.send_json( - {ID: 6, TYPE: "zha/device", ATTR_IEEE: "28:6d:97:00:01:04:11:8c"} - ) - msg = await zha_client.receive_json() - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND - - -async def test_list_groups(zha_client) -> None: - """Test getting ZHA zigbee groups.""" - await zha_client.send_json({ID: 7, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 1 - - for group in groups: - assert group["group_id"] == FIXTURE_GRP_ID - assert group["name"] == FIXTURE_GRP_NAME - assert group["members"] == [] - - -async def test_get_group(zha_client) -> None: - """Test getting a specific ZHA zigbee group.""" - await zha_client.send_json({ID: 8, TYPE: "zha/group", GROUP_ID: FIXTURE_GRP_ID}) - - msg = await zha_client.receive_json() - assert msg["id"] == 8 - assert msg["type"] == const.TYPE_RESULT - - group = msg["result"] - assert group is not None - assert group["group_id"] == FIXTURE_GRP_ID - assert group["name"] == FIXTURE_GRP_NAME - assert group["members"] == [] - - -async def test_get_group_not_found(zha_client) -> None: - """Test not found response from get group API.""" - await zha_client.send_json({ID: 9, TYPE: "zha/group", GROUP_ID: 1_234_567}) - - msg = await zha_client.receive_json() - - assert msg["id"] == 9 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND - - -async def test_list_groupable_devices(zha_client, device_groupable) -> None: - """Test getting ZHA devices that have a group cluster.""" - - await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 10 - assert msg["type"] == const.TYPE_RESULT - - device_endpoints = msg["result"] - assert len(device_endpoints) == 1 - - for endpoint in device_endpoints: - assert endpoint["device"][ATTR_IEEE] == "01:2d:6f:00:0a:90:69:e8" - assert endpoint["device"][ATTR_MANUFACTURER] is not None - assert endpoint["device"][ATTR_MODEL] is not None - assert endpoint["device"][ATTR_NAME] is not None - assert endpoint["device"][ATTR_QUIRK_APPLIED] is not None - assert endpoint["device"]["entities"] is not None - assert endpoint["endpoint_id"] is not None - assert endpoint["entities"] is not None - - for entity_reference in endpoint["device"]["entities"]: - assert entity_reference[ATTR_NAME] is not None - assert entity_reference["entity_id"] is not None - - for entity_reference in endpoint["entities"]: - assert entity_reference["original_name"] is not None - - # Make sure there are no groupable devices when the device is unavailable - # Make device unavailable - device_groupable.available = False - - await zha_client.send_json({ID: 11, TYPE: "zha/devices/groupable"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 11 - assert msg["type"] == const.TYPE_RESULT - - device_endpoints = msg["result"] - assert len(device_endpoints) == 0 - - -async def test_add_group(zha_client) -> None: - """Test adding and getting a new ZHA zigbee group.""" - await zha_client.send_json({ID: 12, TYPE: "zha/group/add", GROUP_NAME: "new_group"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 12 - assert msg["type"] == const.TYPE_RESULT - - added_group = msg["result"] - - assert added_group["name"] == "new_group" - assert added_group["members"] == [] - - await zha_client.send_json({ID: 13, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 13 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 2 - - for group in groups: - assert group["name"] == FIXTURE_GRP_NAME or group["name"] == "new_group" - - -async def test_remove_group(zha_client) -> None: - """Test removing a new ZHA zigbee group.""" - - await zha_client.send_json({ID: 14, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 1 - - await zha_client.send_json( - {ID: 15, TYPE: "zha/group/remove", GROUP_IDS: [FIXTURE_GRP_ID]} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 15 - assert msg["type"] == const.TYPE_RESULT - - groups_remaining = msg["result"] - assert len(groups_remaining) == 0 - - await zha_client.send_json({ID: 16, TYPE: "zha/groups"}) - - msg = await zha_client.receive_json() - assert msg["id"] == 16 - assert msg["type"] == const.TYPE_RESULT - - groups = msg["result"] - assert len(groups) == 0 - - -@pytest.fixture -async def app_controller(hass, setup_zha): - """Fixture for zigpy Application Controller.""" +async def test_async_get_network_settings_missing( + hass, setup_zha, zigpy_app_controller +): + """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() - controller = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].application_controller - p1 = patch.object(controller, "permit") - p2 = patch.object(controller, "permit_with_key", new=AsyncMock()) - with p1, p2: - yield controller + + gateway = api._get_gateway(hass) + await zha.async_unload_entry(hass, gateway.config_entry) + + # Network settings were never loaded for whatever reason + zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() + zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() + + with patch( + "bellows.zigbee.application.ControllerApplication.__new__", + return_value=zigpy_app_controller, + ): + settings = await api.async_get_network_settings(hass) + + assert settings is None -@pytest.mark.parametrize( - ("params", "duration", "node"), - ( - ({}, 60, None), - ({ATTR_DURATION: 30}, 30, None), - ( - {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, - 33, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), - ), - ( - {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, - 60, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), - ), - ), -) -async def test_permit_ha12( - hass: HomeAssistant, - app_controller, - hass_admin_user: MockUser, - params, - duration, - node, -) -> None: - """Test permit service.""" - - await hass.services.async_call( - DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) - ) - 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 +async def test_async_get_network_settings_failure(hass): + """Test reading settings with no ZHA config entries and no database.""" + with pytest.raises(ValueError): + await api.async_get_network_settings(hass) -IC_TEST_PARAMS = ( - ( - { - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", - }, - zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), - ( - { - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_INSTALL_CODE: "52797BF4A5084DAA8E1712B61741CA024051", - }, - zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), -) +async def test_async_get_radio_type_active(hass, setup_zha): + """Test reading the radio type with an active ZHA installation.""" + await setup_zha() + + radio_type = api.async_get_radio_type(hass) + assert radio_type == RadioType.ezsp -@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_TEST_PARAMS) -async def test_permit_with_install_code( - hass: HomeAssistant, - app_controller, - hass_admin_user: MockUser, - params, - src_ieee, - code, -) -> None: - """Test permit service with install code.""" +async def test_async_get_radio_path_active(hass, setup_zha): + """Test reading the radio path with an active ZHA installation.""" + await setup_zha() - await hass.services.async_call( - 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 - - -IC_FAIL_PARAMS = ( - { - # wrong install code - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4052", - }, - # incorrect service params - {ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051"}, - {ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE}, - { - # incorrect service params - ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", - ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", - }, - { - # incorrect service params - ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, - ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", - }, - { - # good regex match, but bad code - ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024052" - }, - { - # good aqara regex match, but bad code - ATTR_QR_CODE: ( - "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" - "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024052" - ) - }, - # good consciot regex match, but bad code - {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024052"}, -) - - -@pytest.mark.parametrize("params", IC_FAIL_PARAMS) -async def test_permit_with_install_code_fail( - hass: HomeAssistant, app_controller, hass_admin_user: MockUser, params -) -> None: - """Test permit service with install code.""" - - with pytest.raises(vol.Invalid): - await hass.services.async_call( - 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 - - -IC_QR_CODE_TEST_PARAMS = ( - ( - {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024051"}, - zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), - ( - {ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051"}, - zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), - ( - { - ATTR_QR_CODE: ( - "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" - "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024051" - ) - }, - zigpy.types.EUI64.convert("04:CF:8C:DF:3C:3C:3C:3C"), - unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), - ), -) - - -@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) -async def test_permit_with_qr_code( - hass: HomeAssistant, - app_controller, - hass_admin_user: MockUser, - params, - src_ieee, - code, -) -> None: - """Test permit service with install code from qr code.""" - - await hass.services.async_call( - 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 - - -@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) -async def test_ws_permit_with_qr_code( - app_controller, zha_client, params, src_ieee, code -) -> None: - """Test permit service with install code from qr code.""" - - await zha_client.send_json( - {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - 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 - - -@pytest.mark.parametrize("params", IC_FAIL_PARAMS) -async def test_ws_permit_with_install_code_fail( - app_controller, zha_client, params -) -> None: - """Test permit ws service with install code.""" - - await zha_client.send_json( - {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] is False - - assert app_controller.permit.await_count == 0 - assert app_controller.permit_with_key.call_count == 0 - - -@pytest.mark.parametrize( - ("params", "duration", "node"), - ( - ({}, 60, None), - ({ATTR_DURATION: 30}, 30, None), - ( - {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, - 33, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), - ), - ( - {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, - 60, - zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), - ), - ), -) -async def test_ws_permit_ha12( - app_controller, zha_client, params, duration, node -) -> None: - """Test permit ws service.""" - - await zha_client.send_json( - {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} - ) - - msg = await zha_client.receive_json() - assert msg["id"] == 14 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - 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 - - -async def test_get_network_settings(app_controller, zha_client) -> None: - """Test current network settings are returned.""" - - await app_controller.backups.create_backup() - - await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/settings"}) - msg = await zha_client.receive_json() - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert "radio_type" in msg["result"] - assert "network_info" in msg["result"]["settings"] - - -async def test_list_network_backups(app_controller, zha_client) -> None: - """Test backups are serialized.""" - - await app_controller.backups.create_backup() - - await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/list"}) - msg = await zha_client.receive_json() - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert "network_info" in msg["result"][0] - - -async def test_create_network_backup(app_controller, zha_client) -> None: - """Test creating backup.""" - - assert not app_controller.backups.backups - await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/create"}) - msg = await zha_client.receive_json() - assert len(app_controller.backups.backups) == 1 - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert "backup" in msg["result"] and "is_complete" in msg["result"] - - -async def test_restore_network_backup_success(app_controller, zha_client) -> None: - """Test successfully restoring a backup.""" - - backup = zigpy.backups.NetworkBackup() - - with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: - await zha_client.send_json( - { - ID: 6, - TYPE: f"{DOMAIN}/network/backups/restore", - "backup": backup.as_dict(), - } - ) - msg = await zha_client.receive_json() - - p.assert_called_once_with(backup) - assert "ezsp" not in backup.network_info.stack_specific - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - -async def test_restore_network_backup_force_write_eui64( - app_controller, zha_client -) -> None: - """Test successfully restoring a backup.""" - - backup = zigpy.backups.NetworkBackup() - - with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: - await zha_client.send_json( - { - ID: 6, - TYPE: f"{DOMAIN}/network/backups/restore", - "backup": backup.as_dict(), - "ezsp_force_write_eui64": True, - } - ) - msg = await zha_client.receive_json() - - # EUI64 will be overwritten - p.assert_called_once_with( - backup.replace( - network_info=backup.network_info.replace( - stack_specific={"ezsp": {EZSP_OVERWRITE_EUI64: True}} - ) - ) - ) - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - - -@patch("zigpy.backups.NetworkBackup.from_dict", new=lambda v: v) -async def test_restore_network_backup_failure(app_controller, zha_client) -> None: - """Test successfully restoring a backup.""" - - with patch.object( - app_controller.backups, - "restore_backup", - new=AsyncMock(side_effect=ValueError("Restore failed")), - ) as p: - await zha_client.send_json( - {ID: 6, TYPE: f"{DOMAIN}/network/backups/restore", "backup": "a backup"} - ) - msg = await zha_client.receive_json() - - p.assert_called_once_with("a backup") - - assert msg["id"] == 6 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_INVALID_FORMAT + radio_path = api.async_get_radio_path(hass) + assert radio_path == "/dev/ttyUSB0" diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index a92631f6da35..23a76de4c250 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -120,7 +120,9 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: ], ) @patch("homeassistant.components.zha.setup_quirks", Mock(return_value=True)) -@patch("homeassistant.components.zha.api.async_load_api", Mock(return_value=True)) +@patch( + "homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True) +) async def test_setup_with_v3_spaces_in_uri( hass: HomeAssistant, path: str, cleaned_path: str ) -> None: diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py new file mode 100644 index 000000000000..7a24daaa3bac --- /dev/null +++ b/tests/components/zha/test_websocket_api.py @@ -0,0 +1,842 @@ +"""Test ZHA WebSocket API.""" +from binascii import unhexlify +from copy import deepcopy +from unittest.mock import AsyncMock, patch + +import pytest +import voluptuous as vol +import zigpy.backups +import zigpy.profiles.zha +import zigpy.types +import zigpy.zcl.clusters.general as general +import zigpy.zcl.clusters.security as security + +from homeassistant.components.websocket_api import const +from homeassistant.components.zha import DOMAIN +from homeassistant.components.zha.core.const import ( + ATTR_CLUSTER_ID, + ATTR_CLUSTER_TYPE, + ATTR_ENDPOINT_ID, + ATTR_ENDPOINT_NAMES, + ATTR_IEEE, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NEIGHBORS, + ATTR_QUIRK_APPLIED, + CLUSTER_TYPE_IN, + DATA_ZHA, + DATA_ZHA_GATEWAY, + EZSP_OVERWRITE_EUI64, + GROUP_ID, + GROUP_IDS, + GROUP_NAME, +) +from homeassistant.components.zha.websocket_api import ( + ATTR_DURATION, + ATTR_INSTALL_CODE, + ATTR_QR_CODE, + ATTR_SOURCE_IEEE, + ID, + SERVICE_PERMIT, + TYPE, + async_load_api, +) +from homeassistant.const import ATTR_NAME, Platform +from homeassistant.core import Context, HomeAssistant + +from .conftest import ( + FIXTURE_GRP_ID, + FIXTURE_GRP_NAME, + SIG_EP_INPUT, + SIG_EP_OUTPUT, + SIG_EP_PROFILE, + SIG_EP_TYPE, +) +from .data import BASE_CUSTOM_CONFIGURATION, CONFIG_WITH_ALARM_OPTIONS + +from tests.common import MockUser + +IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" +IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" + + +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only set up the required and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.ALARM_CONTROL_PANEL, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ), + ): + yield + + +@pytest.fixture +async def device_switch(hass, zigpy_device_mock, zha_device_joined): + """Test ZHA switch platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ieee=IEEE_SWITCH_DEVICE, + ) + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_ias_ace(hass, zigpy_device_mock, zha_device_joined): + """Test alarm control panel device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [security.IasAce.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ) + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_groupable(hass, zigpy_device_mock, zha_device_joined): + """Test ZHA light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.Basic.cluster_id, + general.Groups.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE, + ) + zha_device = await zha_device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def zha_client(hass, hass_ws_client, device_switch, device_groupable): + """Get ZHA WebSocket client.""" + + # load the ZHA API + async_load_api(hass) + return await hass_ws_client(hass) + + +async def test_device_clusters(hass: HomeAssistant, zha_client) -> None: + """Test getting device cluster info.""" + await zha_client.send_json( + {ID: 5, TYPE: "zha/devices/clusters", ATTR_IEEE: IEEE_SWITCH_DEVICE} + ) + + msg = await zha_client.receive_json() + + assert len(msg["result"]) == 2 + + cluster_infos = sorted(msg["result"], key=lambda k: k[ID]) + + cluster_info = cluster_infos[0] + assert cluster_info[TYPE] == CLUSTER_TYPE_IN + assert cluster_info[ID] == 0 + assert cluster_info[ATTR_NAME] == "Basic" + + cluster_info = cluster_infos[1] + assert cluster_info[TYPE] == CLUSTER_TYPE_IN + assert cluster_info[ID] == 6 + assert cluster_info[ATTR_NAME] == "OnOff" + + +async def test_device_cluster_attributes(zha_client) -> None: + """Test getting device cluster attributes.""" + await zha_client.send_json( + { + ID: 5, + TYPE: "zha/devices/clusters/attributes", + ATTR_ENDPOINT_ID: 1, + ATTR_IEEE: IEEE_SWITCH_DEVICE, + ATTR_CLUSTER_ID: 6, + ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, + } + ) + + msg = await zha_client.receive_json() + + attributes = msg["result"] + assert len(attributes) == 7 + + for attribute in attributes: + assert attribute[ID] is not None + assert attribute[ATTR_NAME] is not None + + +async def test_device_cluster_commands(zha_client) -> None: + """Test getting device cluster commands.""" + await zha_client.send_json( + { + ID: 5, + TYPE: "zha/devices/clusters/commands", + ATTR_ENDPOINT_ID: 1, + ATTR_IEEE: IEEE_SWITCH_DEVICE, + ATTR_CLUSTER_ID: 6, + ATTR_CLUSTER_TYPE: CLUSTER_TYPE_IN, + } + ) + + msg = await zha_client.receive_json() + + commands = msg["result"] + assert len(commands) == 6 + + for command in commands: + assert command[ID] is not None + assert command[ATTR_NAME] is not None + assert command[TYPE] is not None + + +async def test_list_devices(zha_client) -> None: + """Test getting ZHA devices.""" + await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) + + msg = await zha_client.receive_json() + + devices = msg["result"] + assert len(devices) == 2 + + msg_id = 100 + for device in devices: + msg_id += 1 + assert device[ATTR_IEEE] is not None + assert device[ATTR_MANUFACTURER] is not None + assert device[ATTR_MODEL] is not None + assert device[ATTR_NAME] is not None + assert device[ATTR_QUIRK_APPLIED] is not None + assert device["entities"] is not None + assert device[ATTR_NEIGHBORS] is not None + assert device[ATTR_ENDPOINT_NAMES] is not None + + for entity_reference in device["entities"]: + assert entity_reference[ATTR_NAME] is not None + assert entity_reference["entity_id"] is not None + + await zha_client.send_json( + {ID: msg_id, TYPE: "zha/device", ATTR_IEEE: device[ATTR_IEEE]} + ) + msg = await zha_client.receive_json() + device2 = msg["result"] + assert device == device2 + + +async def test_get_zha_config(zha_client) -> None: + """Test getting ZHA custom configuration.""" + await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) + + msg = await zha_client.receive_json() + + configuration = msg["result"] + assert configuration == BASE_CUSTOM_CONFIGURATION + + +async def test_get_zha_config_with_alarm( + hass: HomeAssistant, zha_client, device_ias_ace +) -> None: + """Test getting ZHA custom configuration.""" + await zha_client.send_json({ID: 5, TYPE: "zha/configuration"}) + + msg = await zha_client.receive_json() + + configuration = msg["result"] + assert configuration == CONFIG_WITH_ALARM_OPTIONS + + # test that the alarm options are not in the config when we remove the device + device_ias_ace.gateway.device_removed(device_ias_ace.device) + await hass.async_block_till_done() + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + + msg = await zha_client.receive_json() + + configuration = msg["result"] + assert configuration == BASE_CUSTOM_CONFIGURATION + + +async def test_update_zha_config(zha_client, zigpy_app_controller) -> None: + """Test updating ZHA custom configuration.""" + + configuration = deepcopy(CONFIG_WITH_ALARM_OPTIONS) + configuration["data"]["zha_options"]["default_light_transition"] = 10 + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ): + await zha_client.send_json( + {ID: 5, TYPE: "zha/configuration/update", "data": configuration["data"]} + ) + msg = await zha_client.receive_json() + assert msg["success"] + + await zha_client.send_json({ID: 6, TYPE: "zha/configuration"}) + msg = await zha_client.receive_json() + configuration = msg["result"] + assert configuration == configuration + + +async def test_device_not_found(zha_client) -> None: + """Test not found response from get device API.""" + await zha_client.send_json( + {ID: 6, TYPE: "zha/device", ATTR_IEEE: "28:6d:97:00:01:04:11:8c"} + ) + msg = await zha_client.receive_json() + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == const.ERR_NOT_FOUND + + +async def test_list_groups(zha_client) -> None: + """Test getting ZHA zigbee groups.""" + await zha_client.send_json({ID: 7, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 1 + + for group in groups: + assert group["group_id"] == FIXTURE_GRP_ID + assert group["name"] == FIXTURE_GRP_NAME + assert group["members"] == [] + + +async def test_get_group(zha_client) -> None: + """Test getting a specific ZHA zigbee group.""" + await zha_client.send_json({ID: 8, TYPE: "zha/group", GROUP_ID: FIXTURE_GRP_ID}) + + msg = await zha_client.receive_json() + assert msg["id"] == 8 + assert msg["type"] == const.TYPE_RESULT + + group = msg["result"] + assert group is not None + assert group["group_id"] == FIXTURE_GRP_ID + assert group["name"] == FIXTURE_GRP_NAME + assert group["members"] == [] + + +async def test_get_group_not_found(zha_client) -> None: + """Test not found response from get group API.""" + await zha_client.send_json({ID: 9, TYPE: "zha/group", GROUP_ID: 1_234_567}) + + msg = await zha_client.receive_json() + + assert msg["id"] == 9 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == const.ERR_NOT_FOUND + + +async def test_list_groupable_devices(zha_client, device_groupable) -> None: + """Test getting ZHA devices that have a group cluster.""" + + await zha_client.send_json({ID: 10, TYPE: "zha/devices/groupable"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 10 + assert msg["type"] == const.TYPE_RESULT + + device_endpoints = msg["result"] + assert len(device_endpoints) == 1 + + for endpoint in device_endpoints: + assert endpoint["device"][ATTR_IEEE] == "01:2d:6f:00:0a:90:69:e8" + assert endpoint["device"][ATTR_MANUFACTURER] is not None + assert endpoint["device"][ATTR_MODEL] is not None + assert endpoint["device"][ATTR_NAME] is not None + assert endpoint["device"][ATTR_QUIRK_APPLIED] is not None + assert endpoint["device"]["entities"] is not None + assert endpoint["endpoint_id"] is not None + assert endpoint["entities"] is not None + + for entity_reference in endpoint["device"]["entities"]: + assert entity_reference[ATTR_NAME] is not None + assert entity_reference["entity_id"] is not None + + for entity_reference in endpoint["entities"]: + assert entity_reference["original_name"] is not None + + # Make sure there are no groupable devices when the device is unavailable + # Make device unavailable + device_groupable.available = False + + await zha_client.send_json({ID: 11, TYPE: "zha/devices/groupable"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 11 + assert msg["type"] == const.TYPE_RESULT + + device_endpoints = msg["result"] + assert len(device_endpoints) == 0 + + +async def test_add_group(zha_client) -> None: + """Test adding and getting a new ZHA zigbee group.""" + await zha_client.send_json({ID: 12, TYPE: "zha/group/add", GROUP_NAME: "new_group"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 12 + assert msg["type"] == const.TYPE_RESULT + + added_group = msg["result"] + + assert added_group["name"] == "new_group" + assert added_group["members"] == [] + + await zha_client.send_json({ID: 13, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 13 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 2 + + for group in groups: + assert group["name"] == FIXTURE_GRP_NAME or group["name"] == "new_group" + + +async def test_remove_group(zha_client) -> None: + """Test removing a new ZHA zigbee group.""" + + await zha_client.send_json({ID: 14, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 1 + + await zha_client.send_json( + {ID: 15, TYPE: "zha/group/remove", GROUP_IDS: [FIXTURE_GRP_ID]} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 15 + assert msg["type"] == const.TYPE_RESULT + + groups_remaining = msg["result"] + assert len(groups_remaining) == 0 + + await zha_client.send_json({ID: 16, TYPE: "zha/groups"}) + + msg = await zha_client.receive_json() + assert msg["id"] == 16 + assert msg["type"] == const.TYPE_RESULT + + groups = msg["result"] + assert len(groups) == 0 + + +@pytest.fixture +async def app_controller(hass, setup_zha): + """Fixture for zigpy Application Controller.""" + await setup_zha() + controller = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].application_controller + p1 = patch.object(controller, "permit") + p2 = patch.object(controller, "permit_with_key", new=AsyncMock()) + with p1, p2: + yield controller + + +@pytest.mark.parametrize( + ("params", "duration", "node"), + ( + ({}, 60, None), + ({ATTR_DURATION: 30}, 30, None), + ( + {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, + 33, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), + ), + ( + {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, + 60, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), + ), + ), +) +async def test_permit_ha12( + hass: HomeAssistant, + app_controller, + hass_admin_user: MockUser, + params, + duration, + node, +) -> None: + """Test permit service.""" + + await hass.services.async_call( + DOMAIN, SERVICE_PERMIT, params, True, Context(user_id=hass_admin_user.id) + ) + 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 + + +IC_TEST_PARAMS = ( + ( + { + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", + }, + zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), + ( + { + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_INSTALL_CODE: "52797BF4A5084DAA8E1712B61741CA024051", + }, + zigpy.types.EUI64.convert(IEEE_SWITCH_DEVICE), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), +) + + +@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_TEST_PARAMS) +async def test_permit_with_install_code( + hass: HomeAssistant, + app_controller, + hass_admin_user: MockUser, + params, + src_ieee, + code, +) -> None: + """Test permit service with install code.""" + + await hass.services.async_call( + 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 + + +IC_FAIL_PARAMS = ( + { + # wrong install code + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4052", + }, + # incorrect service params + {ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051"}, + {ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE}, + { + # incorrect service params + ATTR_INSTALL_CODE: "5279-7BF4-A508-4DAA-8E17-12B6-1741-CA02-4051", + ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", + }, + { + # incorrect service params + ATTR_SOURCE_IEEE: IEEE_SWITCH_DEVICE, + ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051", + }, + { + # good regex match, but bad code + ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024052" + }, + { + # good aqara regex match, but bad code + ATTR_QR_CODE: ( + "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" + "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024052" + ) + }, + # good consciot regex match, but bad code + {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024052"}, +) + + +@pytest.mark.parametrize("params", IC_FAIL_PARAMS) +async def test_permit_with_install_code_fail( + hass: HomeAssistant, app_controller, hass_admin_user: MockUser, params +) -> None: + """Test permit service with install code.""" + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + 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 + + +IC_QR_CODE_TEST_PARAMS = ( + ( + {ATTR_QR_CODE: "000D6FFFFED4163B|52797BF4A5084DAA8E1712B61741CA024051"}, + zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), + ( + {ATTR_QR_CODE: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051"}, + zigpy.types.EUI64.convert("00:0D:6F:FF:FE:D4:16:3B"), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), + ( + { + ATTR_QR_CODE: ( + "G$M:751$S:357S00001579$D:000000000F350FFD%Z$A:04CF8CDF" + "3C3C3C3C$I:52797BF4A5084DAA8E1712B61741CA024051" + ) + }, + zigpy.types.EUI64.convert("04:CF:8C:DF:3C:3C:3C:3C"), + unhexlify("52797BF4A5084DAA8E1712B61741CA024051"), + ), +) + + +@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) +async def test_permit_with_qr_code( + hass: HomeAssistant, + app_controller, + hass_admin_user: MockUser, + params, + src_ieee, + code, +) -> None: + """Test permit service with install code from qr code.""" + + await hass.services.async_call( + 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 + + +@pytest.mark.parametrize(("params", "src_ieee", "code"), IC_QR_CODE_TEST_PARAMS) +async def test_ws_permit_with_qr_code( + app_controller, zha_client, params, src_ieee, code +) -> None: + """Test permit service with install code from qr code.""" + + await zha_client.send_json( + {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + 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 + + +@pytest.mark.parametrize("params", IC_FAIL_PARAMS) +async def test_ws_permit_with_install_code_fail( + app_controller, zha_client, params +) -> None: + """Test permit ws service with install code.""" + + await zha_client.send_json( + {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] is False + + assert app_controller.permit.await_count == 0 + assert app_controller.permit_with_key.call_count == 0 + + +@pytest.mark.parametrize( + ("params", "duration", "node"), + ( + ({}, 60, None), + ({ATTR_DURATION: 30}, 30, None), + ( + {ATTR_DURATION: 33, ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:dd"}, + 33, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:dd"), + ), + ( + {ATTR_IEEE: "aa:bb:cc:dd:aa:bb:cc:d1"}, + 60, + zigpy.types.EUI64.convert("aa:bb:cc:dd:aa:bb:cc:d1"), + ), + ), +) +async def test_ws_permit_ha12( + app_controller, zha_client, params, duration, node +) -> None: + """Test permit ws service.""" + + await zha_client.send_json( + {ID: 14, TYPE: f"{DOMAIN}/devices/{SERVICE_PERMIT}", **params} + ) + + msg = await zha_client.receive_json() + assert msg["id"] == 14 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + 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 + + +async def test_get_network_settings(app_controller, zha_client) -> None: + """Test current network settings are returned.""" + + await app_controller.backups.create_backup() + + await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/settings"}) + msg = await zha_client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert "radio_type" in msg["result"] + assert "network_info" in msg["result"]["settings"] + + +async def test_list_network_backups(app_controller, zha_client) -> None: + """Test backups are serialized.""" + + await app_controller.backups.create_backup() + + await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/list"}) + msg = await zha_client.receive_json() + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert "network_info" in msg["result"][0] + + +async def test_create_network_backup(app_controller, zha_client) -> None: + """Test creating backup.""" + + assert not app_controller.backups.backups + await zha_client.send_json({ID: 6, TYPE: f"{DOMAIN}/network/backups/create"}) + msg = await zha_client.receive_json() + assert len(app_controller.backups.backups) == 1 + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert "backup" in msg["result"] and "is_complete" in msg["result"] + + +async def test_restore_network_backup_success(app_controller, zha_client) -> None: + """Test successfully restoring a backup.""" + + backup = zigpy.backups.NetworkBackup() + + with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: + await zha_client.send_json( + { + ID: 6, + TYPE: f"{DOMAIN}/network/backups/restore", + "backup": backup.as_dict(), + } + ) + msg = await zha_client.receive_json() + + p.assert_called_once_with(backup) + assert "ezsp" not in backup.network_info.stack_specific + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + +async def test_restore_network_backup_force_write_eui64( + app_controller, zha_client +) -> None: + """Test successfully restoring a backup.""" + + backup = zigpy.backups.NetworkBackup() + + with patch.object(app_controller.backups, "restore_backup", new=AsyncMock()) as p: + await zha_client.send_json( + { + ID: 6, + TYPE: f"{DOMAIN}/network/backups/restore", + "backup": backup.as_dict(), + "ezsp_force_write_eui64": True, + } + ) + msg = await zha_client.receive_json() + + # EUI64 will be overwritten + p.assert_called_once_with( + backup.replace( + network_info=backup.network_info.replace( + stack_specific={"ezsp": {EZSP_OVERWRITE_EUI64: True}} + ) + ) + ) + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + +@patch("zigpy.backups.NetworkBackup.from_dict", new=lambda v: v) +async def test_restore_network_backup_failure(app_controller, zha_client) -> None: + """Test successfully restoring a backup.""" + + with patch.object( + app_controller.backups, + "restore_backup", + new=AsyncMock(side_effect=ValueError("Restore failed")), + ) as p: + await zha_client.send_json( + {ID: 6, TYPE: f"{DOMAIN}/network/backups/restore", "backup": "a backup"} + ) + msg = await zha_client.receive_json() + + p.assert_called_once_with("a backup") + + assert msg["id"] == 6 + assert msg["type"] == const.TYPE_RESULT + assert not msg["success"] + assert msg["error"]["code"] == const.ERR_INVALID_FORMAT From cc4ff553471044fbf49f578b2694db87af300525 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Mar 2023 17:02:49 +0100 Subject: [PATCH 0683/1058] Update pvo to 1.0.0 (#90109) --- homeassistant/components/pvoutput/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index b8869cdee758..b78f49b74f9c 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==0.2.2"] + "requirements": ["pvo==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 91b480e973a4..36bea3aeba49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1421,7 +1421,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==0.2.2 +pvo==1.0.0 # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b88020affdf..a93f5691795f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1039,7 +1039,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==0.2.2 +pvo==1.0.0 # homeassistant.components.canary py-canary==0.5.3 From 94e247dc69a9b560ffd86b2f6cd922cc90f805d3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 18:52:52 +0100 Subject: [PATCH 0684/1058] Fix islamic_prayer_times setup (#90122) --- homeassistant/components/islamic_prayer_times/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 95a7db632b11..d8810b0ad45f 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.async_on_unload( config_entry.add_update_listener(async_options_updated) ) - hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True From 6db8867b81552343bc4e640fe64ec3e7a7c30a3d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Mar 2023 18:55:50 +0100 Subject: [PATCH 0685/1058] Update wled to 0.16.0 (#90120) --- homeassistant/components/wled/coordinator.py | 4 ++-- homeassistant/components/wled/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wled/snapshots/test_diagnostics.ambr | 1 + tests/components/wled/test_coordinator.py | 4 ++-- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 5afb5a6b44ee..9ba3fd2cb3d0 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -1,7 +1,7 @@ """DataUpdateCoordinator for WLED.""" from __future__ import annotations -from wled import WLED, Device as WLEDDevice, WLEDConnectionClosed, WLEDError +from wled import WLED, Device as WLEDDevice, WLEDConnectionClosedError, WLEDError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -68,7 +68,7 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): try: await self.wled.listen(callback=self.async_set_updated_data) - except WLEDConnectionClosed as err: + except WLEDConnectionClosedError as err: self.last_update_success = False self.logger.info(err) except WLEDError as err: diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 99309b9f0080..b6d205912c6d 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "platinum", - "requirements": ["wled==0.15.0"], + "requirements": ["wled==0.16.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 36bea3aeba49..42355c107367 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2638,7 +2638,7 @@ wirelesstagpy==0.8.1 withings-api==2.4.0 # homeassistant.components.wled -wled==0.15.0 +wled==0.16.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a93f5691795f..f9c4ae887606 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1878,7 +1878,7 @@ wiffi==1.1.2 withings-api==2.4.0 # homeassistant.components.wled -wled==0.15.0 +wled==0.16.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr index e06608033ca0..25db6a3116b1 100644 --- a/tests/components/wled/snapshots/test_diagnostics.ambr +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -92,6 +92,7 @@ 'effect_count': 81, 'filesystem': None, 'free_heap': 14600, + 'ip': 'Unknown', 'leds': dict({ '__type': "", 'repr': 'Leds(cct=False, count=30, fps=None, light_capabilities=None, max_power=850, max_segments=10, power=470, rgbw=False, wv=True, segment_light_capabilities=None)', diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index 04d1c8f435bd..89817fb8569b 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock import pytest from wled import ( Device as WLEDDevice, - WLEDConnectionClosed, + WLEDConnectionClosedError, WLEDConnectionError, WLEDError, ) @@ -124,7 +124,7 @@ async def test_websocket( assert state.state == STATE_OFF # Resolve Future with a connection losed. - connection_finished.set_exception(WLEDConnectionClosed) + connection_finished.set_exception(WLEDConnectionClosedError) await hass.async_block_till_done() # Disconnect called, unsubbed Home Assistant stop listener From 3931e11fd92807f7d8ecbe110b2ff2df7fb73f36 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 20:10:10 +0100 Subject: [PATCH 0686/1058] Try to load integration before starting option flow (#90111) * Try to load integration before starting option flow * Adjust tests --- homeassistant/config_entries.py | 45 +++++++++++-------- .../components/config/test_config_entries.py | 4 ++ .../test_config_flow.py | 3 ++ .../helpers/test_schema_config_entry_flow.py | 14 +++++- tests/test_config_entries.py | 4 ++ 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 3ab16f69676d..b21ae391e2a8 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -954,25 +954,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): Handler key is the domain of the component that we want to set up. """ - try: - integration = await loader.async_get_integration(self.hass, handler_key) - except loader.IntegrationNotFound as err: - _LOGGER.error("Cannot find integration %s", handler_key) - raise data_entry_flow.UnknownHandler from err - - # Make sure requirements and dependencies of component are resolved - await async_process_deps_reqs(self.hass, self._hass_config, integration) - - try: - integration.get_platform("config_flow") - except ImportError as err: - _LOGGER.error( - "Error occurred loading configuration flow for integration %s: %s", - handler_key, - err, - ) - raise data_entry_flow.UnknownHandler - + await _load_integration(self.hass, handler_key, self._hass_config) if (handler := HANDLERS.get(handler_key)) is None: raise data_entry_flow.UnknownHandler @@ -1842,6 +1824,8 @@ class OptionsFlowManager(data_entry_flow.FlowManager): if entry is None: raise UnknownEntry(handler_key) + await _load_integration(self.hass, entry.domain, {}) + if entry.domain not in HANDLERS: raise data_entry_flow.UnknownHandler @@ -2006,3 +1990,26 @@ async def support_remove_from_device(hass: HomeAssistant, domain: str) -> bool: integration = await loader.async_get_integration(hass, domain) component = integration.get_component() return hasattr(component, "async_remove_config_entry_device") + + +async def _load_integration( + hass: HomeAssistant, domain: str, hass_config: ConfigType +) -> None: + try: + integration = await loader.async_get_integration(hass, domain) + except loader.IntegrationNotFound as err: + _LOGGER.error("Cannot find integration %s", domain) + raise data_entry_flow.UnknownHandler from err + + # Make sure requirements and dependencies of component are resolved + await async_process_deps_reqs(hass, hass_config, integration) + + try: + integration.get_platform("config_flow") + except ImportError as err: + _LOGGER.error( + "Error occurred loading flow for integration %s: %s", + domain, + err, + ) + raise data_entry_flow.UnknownHandler diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index cf8df6aef682..f861d887b99e 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -793,6 +793,8 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: return OptionsFlowHandler() + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) MockConfigEntry( domain="test", entry_id="test1", @@ -824,6 +826,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) + mock_entity_platform(hass, "config_flow.test", None) class TestFlow(core_ce.ConfigFlow): @staticmethod @@ -889,6 +892,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No mock_integration( hass, MockModule("test", async_setup_entry=AsyncMock(return_value=True)) ) + mock_entity_platform(hass, "config_flow.test", None) class TestFlow(core_ce.ConfigFlow): @staticmethod diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 6ef3d13636e8..c74adbf32eaa 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.zha.core.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration @@ -159,6 +160,7 @@ async def test_option_flow_install_multi_pan_addon( start_addon, ) -> None: """Test installing the multi pan addon.""" + assert await async_setup_component(hass, "usb", {}) mock_integration(hass, MockModule("hassio")) # Setup the config entry @@ -253,6 +255,7 @@ async def test_option_flow_install_multi_pan_addon_zha( start_addon, ) -> None: """Test installing the multi pan addon when a zha config entry exists.""" + assert await async_setup_component(hass, "usb", {}) mock_integration(hass, MockModule("hassio")) # Setup the config entry diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 9919a53839e8..0bc8e0f1ff37 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -23,7 +23,13 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from homeassistant.util.decorator import Registry -from tests.common import MockConfigEntry, mock_platform +from tests.common import ( + MockConfigEntry, + MockModule, + mock_entity_platform, + mock_integration, + mock_platform, +) TEST_DOMAIN = "test" @@ -226,6 +232,8 @@ async def test_options_flow_advanced_option( config_flow = {} options_flow = OPTIONS_FLOW + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -513,6 +521,8 @@ async def test_suggested_values( config_flow = {} options_flow = OPTIONS_FLOW + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) config_entry = MockConfigEntry( data={}, domain="test", @@ -624,6 +634,8 @@ async def test_options_flow_state(hass: HomeAssistant) -> None: config_flow = {} options_flow = OPTIONS_FLOW + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) config_entry = MockConfigEntry( data={}, domain="test", diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 29041730da20..c8cdc5619858 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1101,6 +1101,8 @@ async def test_entry_options( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can set options on an entry.""" + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) @@ -1137,6 +1139,8 @@ async def test_entry_options_abort( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: """Test that we can abort options flow.""" + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_manager(manager) From 4c98495fe067acd7c51546c5a923417c1c938fa3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 09:19:43 -1000 Subject: [PATCH 0687/1058] Bump ulid-transform to 0.5.1 (#90123) changelog: https://github.com/bdraco/ulid-transform/compare/v0.4.2...v0.5.1 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index edc88caef8a9..ffef59913e00 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ requests==2.28.2 scapy==2.5.0 sqlalchemy==2.0.6 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.2 +ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 diff --git a/pyproject.toml b/pyproject.toml index 3ee9bc7be5c9..d8ba8e747545 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "pyyaml==6.0", "requests==2.28.2", "typing-extensions>=4.5.0,<5.0", - "ulid-transform==0.4.2", + "ulid-transform==0.5.1", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.8.1", diff --git a/requirements.txt b/requirements.txt index 168488a54d6c..1b4874e2c4c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ python-slugify==4.0.1 pyyaml==6.0 requests==2.28.2 typing-extensions>=4.5.0,<5.0 -ulid-transform==0.4.2 +ulid-transform==0.5.1 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.8.1 From 1ea3312ed4d7dd53b8f878292a423cf305b2a020 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 20:20:42 +0100 Subject: [PATCH 0688/1058] Deduplicate multiprotocol addon helper (#90102) * Deduplicate multiprotocol addon helper * Clarify --- .../silabs_multiprotocol_addon.py | 51 +++++++++++++ .../homeassistant_sky_connect/__init__.py | 71 +++---------------- .../homeassistant_yellow/__init__.py | 55 +++----------- .../homeassistant_sky_connect/test_init.py | 8 +-- 4 files changed, 75 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 20fdb97e3842..bba6b447c7e2 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -26,6 +26,7 @@ from homeassistant.data_entry_flow import ( FlowManager, FlowResult, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.singleton import singleton from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -356,3 +357,53 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): if user_input is None: return self.async_show_form(step_id="addon_installed_other_device") return self.async_create_entry(title="", data={}) + + +async def check_multi_pan_addon(hass: HomeAssistant) -> None: + """Check the multi-PAN addon state, and start it if installed but not started. + + Does nothing if Hass.io is not loaded. + Raises on error or if the add-on is installed but not started. + """ + if not is_hassio(hass): + return + + addon_manager: AddonManager = get_addon_manager(hass) + try: + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + except AddonError as err: + _LOGGER.error(err) + raise HomeAssistantError from err + + # Request the addon to start if it's not started + # addon_manager.async_start_addon returns as soon as the start request has been sent + # and does not wait for the addon to be started, so we raise below + if addon_info.state == AddonState.NOT_RUNNING: + await addon_manager.async_start_addon() + + if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): + _LOGGER.debug("Multi pan addon installed and in state %s", addon_info.state) + raise HomeAssistantError + + +async def get_multi_pan_addon_info( + hass: HomeAssistant, device_path: str +) -> AddonInfo | None: + """Return AddonInfo if the multi-PAN addon is using the given device. + + Returns None if Hass.io is not loaded, the addon is not running or the addon is + connected to another device. + """ + if not is_hassio(hass): + return None + + addon_manager: AddonManager = get_addon_manager(hass) + addon_info: AddonInfo = await addon_manager.async_get_addon_info() + + if addon_info.state != AddonState.RUNNING: + return None + + if addon_info.options["device"] != device_path: + return None + + return addon_info diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 1de919b8c707..54c11fd37928 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -1,75 +1,19 @@ """The Home Assistant SkyConnect integration.""" from __future__ import annotations -import logging - from homeassistant.components import usb -from homeassistant.components.hassio import ( - AddonError, - AddonInfo, - AddonManager, - AddonState, - is_hassio, -) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( - get_addon_manager, + check_multi_pan_addon, + get_multi_pan_addon_info, get_zigbee_socket, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from .const import DOMAIN from .util import get_usb_service_info -_LOGGER = logging.getLogger(__name__) - - -async def _wait_multi_pan_addon(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Wait for multi-PAN info to be available.""" - if not is_hassio(hass): - return - - addon_manager: AddonManager = get_addon_manager(hass) - try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - except AddonError as err: - _LOGGER.error(err) - raise ConfigEntryNotReady from err - - # Start the addon if it's not started - if addon_info.state == AddonState.NOT_RUNNING: - await addon_manager.async_start_addon() - - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): - _LOGGER.debug( - "Multi pan addon in state %s, delaying yellow config entry setup", - addon_info.state, - ) - raise ConfigEntryNotReady - - -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for our SkyConnect.""" - if not is_hassio(hass): - return None - - addon_manager: AddonManager = get_addon_manager(hass) - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - - if addon_info.state != AddonState.RUNNING: - return None - - usb_dev = entry.data["device"] - dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) - - if addon_info.options["device"] != dev_path: - return None - - return addon_info - async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: """Finish Home Assistant SkyConnect config entry setup.""" @@ -87,7 +31,9 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return - addon_info = await _multi_pan_addon_info(hass, entry) + usb_dev = entry.data["device"] + dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) + addon_info = await get_multi_pan_addon_info(hass, dev_path) if not addon_info: usb_info = get_usb_service_info(entry) @@ -115,7 +61,10 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant SkyConnect config entry.""" - await _wait_multi_pan_addon(hass, entry) + try: + await check_multi_pan_addon(hass) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err @callback def async_usb_scan_done() -> None: diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 9e22736fc71c..72df6a5707bb 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -1,58 +1,18 @@ """The Home Assistant Yellow integration.""" from __future__ import annotations -import logging - -from homeassistant.components.hassio import ( - AddonError, - AddonInfo, - AddonManager, - AddonState, - get_os_info, -) +from homeassistant.components.hassio import get_os_info from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( - get_addon_manager, + check_multi_pan_addon, + get_multi_pan_addon_info, get_zigbee_socket, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from .const import RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA -_LOGGER = logging.getLogger(__name__) - - -async def _multi_pan_addon_info( - hass: HomeAssistant, entry: ConfigEntry -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is enabled for the Yellow's radio.""" - addon_manager: AddonManager = get_addon_manager(hass) - try: - addon_info: AddonInfo = await addon_manager.async_get_addon_info() - except AddonError as err: - _LOGGER.error(err) - raise ConfigEntryNotReady from err - - # Start the addon if it's not started - if addon_info.state == AddonState.NOT_RUNNING: - await addon_manager.async_start_addon() - - if addon_info.state not in (AddonState.NOT_INSTALLED, AddonState.RUNNING): - _LOGGER.debug( - "Multi pan addon in state %s, delaying yellow config entry setup", - addon_info.state, - ) - raise ConfigEntryNotReady - - if addon_info.state == AddonState.NOT_INSTALLED: - return None - - if addon_info.options["device"] != RADIO_DEVICE: - return None - - return addon_info - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant Yellow config entry.""" @@ -66,7 +26,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) return False - addon_info = await _multi_pan_addon_info(hass, entry) + try: + await check_multi_pan_addon(hass) + except HomeAssistantError as err: + raise ConfigEntryNotReady from err + + addon_info = await get_multi_pan_addon_info(hass, RADIO_DEVICE) if not addon_info: hw_discovery_data = ZHA_HW_DISCOVERY_DATA diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 3794b91a9e40..746e119082cd 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -172,7 +172,7 @@ async def test_setup_zha_multipan( ) as mock_is_plugged_in, patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -226,7 +226,7 @@ async def test_setup_zha_multipan_other_device( ) as mock_is_plugged_in, patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -304,7 +304,7 @@ async def test_setup_entry_addon_info_fails( ), patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) @@ -333,7 +333,7 @@ async def test_setup_entry_addon_not_running( ), patch( "homeassistant.components.onboarding.async_is_onboarded", return_value=False ), patch( - "homeassistant.components.homeassistant_sky_connect.is_hassio", + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): assert not await hass.config_entries.async_setup(config_entry.entry_id) From 5948347b6bd3030ffc2d33d3ac2679d3a655bc95 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Mar 2023 20:24:05 +0100 Subject: [PATCH 0689/1058] Fix switch_as_x entity naming (#89992) * Fix switch_as_x entity naming * Simplify name logic --- homeassistant/components/switch_as_x/cover.py | 6 +- .../components/switch_as_x/entity.py | 36 ++++++--- homeassistant/components/switch_as_x/fan.py | 6 +- homeassistant/components/switch_as_x/light.py | 6 +- homeassistant/components/switch_as_x/lock.py | 6 +- homeassistant/components/switch_as_x/siren.py | 6 +- tests/components/switch_as_x/test_cover.py | 23 +++--- tests/components/switch_as_x/test_fan.py | 23 +++--- tests/components/switch_as_x/test_init.py | 74 +++++++++++++++++-- tests/components/switch_as_x/test_light.py | 4 +- tests/components/switch_as_x/test_lock.py | 19 ++--- tests/components/switch_as_x/test_siren.py | 23 +++--- 12 files changed, 148 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index 9d7a7bf61788..b7f8e5bf971e 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -30,18 +30,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ CoverSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index ac56b4c6078c..8432c46f856a 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -10,32 +10,47 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, STATE_UNAVAILABLE, - EntityCategory, ) -from homeassistant.core import Event, callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import Entity, ToggleEntity +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity from homeassistant.helpers.event import async_track_state_change_event from .const import DOMAIN as SWITCH_AS_X_DOMAIN class BaseEntity(Entity): - """Represents a Switch as a X.""" + """Represents a Switch as an X.""" _attr_should_poll = False def __init__( self, - name: str, + hass: HomeAssistant, + config_entry_title: str, switch_entity_id: str, unique_id: str | None, - device_id: str | None, - entity_category: EntityCategory | None, ) -> None: - """Initialize Light Switch.""" + """Initialize Switch as an X.""" + registry = er.async_get(hass) + device_registry = dr.async_get(hass) + wrapped_switch = registry.async_get(switch_entity_id) + device_id = wrapped_switch.device_id if wrapped_switch else None + entity_category = wrapped_switch.entity_category if wrapped_switch else None + has_entity_name = wrapped_switch.has_entity_name if wrapped_switch else False + + name: str | None = config_entry_title + if wrapped_switch: + name = wrapped_switch.name or wrapped_switch.original_name + self._device_id = device_id + if device_id and (device := device_registry.async_get(device_id)): + self._attr_device_info = DeviceInfo( + connections=device.connections, + identifiers=device.identifiers, + ) self._attr_entity_category = entity_category + self._attr_has_entity_name = has_entity_name self._attr_name = name self._attr_unique_id = unique_id self._switch_entity_id = switch_entity_id @@ -69,10 +84,9 @@ class BaseEntity(Entity): # Call once on adding _async_state_changed_listener() - # Add this entity to the wrapped switch's device + # Update entity options registry = er.async_get(self.hass) if registry.async_get(self.entity_id) is not None: - registry.async_update_entity(self.entity_id, device_id=self._device_id) registry.async_update_entity_options( self.entity_id, SWITCH_AS_X_DOMAIN, diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index bfc4d2e037e9..87a6c3872958 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -23,18 +23,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ FanSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/light.py b/homeassistant/components/switch_as_x/light.py index c8181bf35f80..7bcdb659e9ce 100644 --- a/homeassistant/components/switch_as_x/light.py +++ b/homeassistant/components/switch_as_x/light.py @@ -21,18 +21,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ LightSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index a0aac15a702a..e3c29a1cf424 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -30,18 +30,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ LockSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/homeassistant/components/switch_as_x/siren.py b/homeassistant/components/switch_as_x/siren.py index 635aa4e2d79b..88ff9a322d30 100644 --- a/homeassistant/components/switch_as_x/siren.py +++ b/homeassistant/components/switch_as_x/siren.py @@ -21,18 +21,14 @@ async def async_setup_entry( entity_id = er.async_validate_entity_id( registry, config_entry.options[CONF_ENTITY_ID] ) - wrapped_switch = registry.async_get(entity_id) - device_id = wrapped_switch.device_id if wrapped_switch else None - entity_category = wrapped_switch.entity_category if wrapped_switch else None async_add_entities( [ SirenSwitch( + hass, config_entry.title, entity_id, config_entry.entry_id, - device_id, - entity_category, ) ] ) diff --git a/tests/components/switch_as_x/test_cover.py b/tests/components/switch_as_x/test_cover.py index d8317a51b8c8..d0aef0b94906 100644 --- a/tests/components/switch_as_x/test_cover.py +++ b/tests/components/switch_as_x/test_cover.py @@ -45,6 +45,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(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, @@ -52,43 +53,43 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.COVER, }, - title="garage_door", + title="Title is ignored", ) 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.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN await hass.services.async_call( COVER_DOMAIN, SERVICE_TOGGLE, - {CONF_ENTITY_ID: "cover.garage_door"}, + {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("cover.garage_door").state == STATE_CLOSED + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, - {CONF_ENTITY_ID: "cover.garage_door"}, + {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, - {CONF_ENTITY_ID: "cover.garage_door"}, + {CONF_ENTITY_ID: "cover.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("cover.garage_door").state == STATE_CLOSED + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -98,7 +99,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN await hass.services.async_call( SWITCH_DOMAIN, @@ -108,7 +109,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("cover.garage_door").state == STATE_CLOSED + assert hass.states.get("cover.decorative_lights").state == STATE_CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -118,4 +119,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("cover.garage_door").state == STATE_OPEN + assert hass.states.get("cover.decorative_lights").state == STATE_OPEN diff --git a/tests/components/switch_as_x/test_fan.py b/tests/components/switch_as_x/test_fan.py index b7b746344b38..cf6789d439cd 100644 --- a/tests/components/switch_as_x/test_fan.py +++ b/tests/components/switch_as_x/test_fan.py @@ -41,6 +41,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(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, @@ -48,43 +49,43 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.FAN, }, - title="wind_machine", + title="Title is ignored", ) 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.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TOGGLE, - {CONF_ENTITY_ID: "fan.wind_machine"}, + {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("fan.wind_machine").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.wind_machine"}, + {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("fan.wind_machine").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.wind_machine"}, + {CONF_ENTITY_ID: "fan.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("fan.wind_machine").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -94,7 +95,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, @@ -104,7 +105,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("fan.wind_machine").state == STATE_OFF + assert hass.states.get("fan.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -114,4 +115,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("fan.wind_machine").state == STATE_ON + assert hass.states.get("fan.decorative_lights").state == STATE_ON diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index a95725999d92..2d63ce9617b6 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -71,7 +71,9 @@ async def test_entity_registry_events( ) -> None: """Test entity registry events are tracked.""" registry = er.async_get(hass) - registry_entry = registry.async_get_or_create("switch", "test", "unique") + registry_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) switch_entity_id = registry_entry.entity_id hass.states.async_set(switch_entity_id, STATE_ON) @@ -144,6 +146,7 @@ async def test_device_registry_config_entry_1( "unique", config_entry=switch_config_entry, device_id=device_entry.id, + original_name="ABC", ) # Add another config entry to the same device device_registry.async_update_device( @@ -202,6 +205,7 @@ async def test_device_registry_config_entry_2( "unique", config_entry=switch_config_entry, device_id=device_entry.id, + original_name="ABC", ) switch_as_x_config_entry = MockConfigEntry( @@ -272,7 +276,9 @@ async def test_config_entry_entity_id( async def test_config_entry_uuid(hass: HomeAssistant, target_domain: Platform) -> None: """Test light switch setup from config entry with entity registry id.""" registry = er.async_get(hass) - registry_entry = registry.async_get_or_create("switch", "test", "unique") + registry_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) config_entry = MockConfigEntry( data={}, @@ -305,7 +311,7 @@ async def test_device(hass: HomeAssistant, target_domain: Platform) -> None: connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) switch_entity_entry = entity_registry.async_get_or_create( - "switch", "test", "unique", device_id=device_entry.id + "switch", "test", "unique", device_id=device_entry.id, original_name="ABC" ) switch_as_x_config_entry = MockConfigEntry( @@ -414,7 +420,9 @@ async def test_entity_category_inheritance( """Test the entity category is inherited from source device.""" registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") + switch_entity_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) registry.async_update_entity( switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG ) @@ -448,7 +456,9 @@ async def test_entity_options( """Test the source entity is stored as an entity option.""" registry = er.async_get(hass) - switch_entity_entry = registry.async_get_or_create("switch", "test", "unique") + switch_entity_entry = registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) registry.async_update_entity( switch_entity_entry.entity_id, entity_category=EntityCategory.CONFIG ) @@ -474,3 +484,57 @@ async def test_entity_options( assert entity_entry.options == { DOMAIN: {"entity_id": switch_entity_entry.entity_id} } + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_entity_name( + hass: HomeAssistant, + target_domain: Platform, +) -> None: + """Test the source entity has entity_name set to True.""" + registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + switch_config_entry = MockConfigEntry() + + device_entry = device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + name="Device name", + ) + + switch_entity_entry = registry.async_get_or_create( + "switch", + "test", + "unique", + device_id=device_entry.id, + has_entity_name=True, + ) + switch_entity_entry = registry.async_update_entity( + switch_entity_entry.entity_id, + config_entry_id=switch_config_entry.entry_id, + ) + + # Add the config entry + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + ) + 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() + + entity_entry = registry.async_get(f"{target_domain}.device_name") + assert entity_entry + assert entity_entry.device_id == switch_entity_entry.device_id + assert entity_entry.has_entity_name is True + assert entity_entry.original_name is None + assert entity_entry.options == { + DOMAIN: {"entity_id": switch_entity_entry.entity_id} + } diff --git a/tests/components/switch_as_x/test_light.py b/tests/components/switch_as_x/test_light.py index b5976f178416..9a33bab20a89 100644 --- a/tests/components/switch_as_x/test_light.py +++ b/tests/components/switch_as_x/test_light.py @@ -58,6 +58,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_light_service_calls(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, @@ -111,6 +112,7 @@ async def test_light_service_calls(hass: HomeAssistant) -> None: async def test_switch_service_calls(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, @@ -118,7 +120,7 @@ async def test_switch_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.LIGHT, }, - title="decorative_lights", + title="Title is ignored", ) 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 de4c729e492d..6d30ac4646bd 100644 --- a/tests/components/switch_as_x/test_lock.py +++ b/tests/components/switch_as_x/test_lock.py @@ -44,6 +44,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(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, @@ -51,33 +52,33 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.LOCK, }, - title="candy_jar", + title="Title is ignored", ) 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.candy_jar").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, - {CONF_ENTITY_ID: "lock.candy_jar"}, + {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.candy_jar").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, - {CONF_ENTITY_ID: "lock.candy_jar"}, + {CONF_ENTITY_ID: "lock.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.candy_jar").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -87,7 +88,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.candy_jar").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -97,7 +98,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.candy_jar").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -107,4 +108,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.candy_jar").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED diff --git a/tests/components/switch_as_x/test_siren.py b/tests/components/switch_as_x/test_siren.py index 2b3dedf6fb88..f776ab2ae014 100644 --- a/tests/components/switch_as_x/test_siren.py +++ b/tests/components/switch_as_x/test_siren.py @@ -41,6 +41,7 @@ async def test_default_state(hass: HomeAssistant) -> None: async def test_service_calls(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, @@ -48,43 +49,43 @@ async def test_service_calls(hass: HomeAssistant) -> None: CONF_ENTITY_ID: "switch.decorative_lights", CONF_TARGET_DOMAIN: Platform.SIREN, }, - title="noise_maker", + title="Title is ignored", ) 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.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON await hass.services.async_call( SIREN_DOMAIN, SERVICE_TOGGLE, - {CONF_ENTITY_ID: "siren.noise_maker"}, + {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("siren.noise_maker").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.noise_maker"}, + {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("siren.noise_maker").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.noise_maker"}, + {CONF_ENTITY_ID: "siren.decorative_lights"}, blocking=True, ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("siren.noise_maker").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -94,7 +95,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, @@ -104,7 +105,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("siren.noise_maker").state == STATE_OFF + assert hass.states.get("siren.decorative_lights").state == STATE_OFF await hass.services.async_call( SWITCH_DOMAIN, @@ -114,4 +115,4 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("siren.noise_maker").state == STATE_ON + assert hass.states.get("siren.decorative_lights").state == STATE_ON From 4ebce9746db2da6f0f863d35126f4d67c7e5d4f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 10:05:23 -1000 Subject: [PATCH 0690/1058] Add schema auto repairs for states tables (#90083) --- .../recorder/auto_repairs/schema.py | 218 ++++++++++++++ .../recorder/auto_repairs/states/schema.py | 39 +++ .../auto_repairs/statistics/schema.py | 280 +----------------- .../components/recorder/db_schema.py | 17 +- .../components/recorder/migration.py | 86 ++++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- .../recorder/auto_repairs/states/__init__.py | 5 + .../auto_repairs/states/test_schema.py | 106 +++++++ .../auto_repairs/statistics/test_schema.py | 178 +---------- .../recorder/auto_repairs/test_schema.py | 253 ++++++++++++++++ tests/conftest.py | 32 +- 11 files changed, 731 insertions(+), 485 deletions(-) create mode 100644 homeassistant/components/recorder/auto_repairs/schema.py create mode 100644 homeassistant/components/recorder/auto_repairs/states/schema.py create mode 100644 tests/components/recorder/auto_repairs/states/__init__.py create mode 100644 tests/components/recorder/auto_repairs/states/test_schema.py create mode 100644 tests/components/recorder/auto_repairs/test_schema.py diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py new file mode 100644 index 000000000000..ec05eafd1406 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -0,0 +1,218 @@ +"""Schema repairs.""" +from __future__ import annotations + +from collections.abc import Iterable, Mapping +import logging +from typing import TYPE_CHECKING + +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm.attributes import InstrumentedAttribute + +from ..const import SupportedDialect +from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE +from ..util import session_scope + +if TYPE_CHECKING: + from .. import Recorder + +_LOGGER = logging.getLogger(__name__) + +MYSQL_ERR_INCORRECT_STRING_VALUE = 1366 + +# This name can't be represented unless 4-byte UTF-8 unicode is supported +UTF8_NAME = "𓆚𓃗" + +# This number can't be accurately represented as a 32-bit float +PRECISE_NUMBER = 1.000000000000001 + + +def _get_precision_column_types( + table_object: type[DeclarativeBase], +) -> list[str]: + """Get the column names for the columns that need to be checked for precision.""" + return [ + column.key + for column in table_object.__table__.columns + if column.type is DOUBLE_TYPE + ] + + +def validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Lack of full utf8 support is only an issue for MySQL / MariaDB + if instance.dialect_name != SupportedDialect.MYSQL: + return schema_errors + + try: + schema_errors = _validate_table_schema_supports_utf8( + instance, table_object, columns + ) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_table_schema_supports_utf8( + instance: Recorder, + table_object: type[DeclarativeBase], + columns: tuple[InstrumentedAttribute, ...], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column.key: UTF8_NAME for column in columns}) + table = table_object.__tablename__ + # Try inserting some data which needs utf8mb4 support + session.add(db_object) + try: + session.flush() + except OperationalError as err: + if err.orig and err.orig.args[0] == MYSQL_ERR_INCORRECT_STRING_VALUE: + _LOGGER.debug( + "Database %s statistics_meta does not support 4-byte UTF-8", + table, + ) + schema_errors.add(f"{table}.4-byte UTF-8") + return schema_errors + raise + finally: + session.rollback() + return schema_errors + + +def validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL + if instance.dialect_name not in ( + SupportedDialect.MYSQL, + SupportedDialect.POSTGRESQL, + ): + return schema_errors + try: + schema_errors = _validate_db_schema_precision(instance, table_object) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception("Error when validating DB schema: %s", exc) + + _log_schema_errors(table_object, schema_errors) + return schema_errors + + +def _validate_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], +) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + columns = _get_precision_column_types(table_object) + # Mark the session as read_only to ensure that the test data is not committed + # to the database and we always rollback when the scope is exited + with session_scope(session=instance.get_session(), read_only=True) as session: + db_object = table_object(**{column: PRECISE_NUMBER for column in columns}) + table = table_object.__tablename__ + try: + session.add(db_object) + session.flush() + session.refresh(db_object) + _check_columns( + schema_errors=schema_errors, + stored={column: getattr(db_object, column) for column in columns}, + expected={column: PRECISE_NUMBER for column in columns}, + columns=columns, + table_name=table, + supports="double precision", + ) + finally: + session.rollback() + return schema_errors + + +def _log_schema_errors( + table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Log schema errors.""" + if not schema_errors: + return + _LOGGER.debug( + "Detected %s schema errors: %s", + table_object.__tablename__, + ", ".join(sorted(schema_errors)), + ) + + +def _check_columns( + schema_errors: set[str], + stored: Mapping, + expected: Mapping, + columns: Iterable[str], + table_name: str, + supports: str, +) -> None: + """Check that the columns in the table support the given feature. + + Errors are logged and added to the schema_errors set. + """ + for column in columns: + if stored[column] == expected[column]: + continue + schema_errors.add(f"{table_name}.{supports}") + _LOGGER.error( + "Column %s in database table %s does not support %s (stored=%s != expected=%s)", + column, + table_name, + supports, + stored[column], + expected[column], + ) + + +def correct_db_schema_utf8( + instance: Recorder, table_object: type[DeclarativeBase], schema_errors: set[str] +) -> None: + """Correct utf8 issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + if f"{table_name}.4-byte UTF-8" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _correct_table_character_set_and_collation, + ) + + _correct_table_character_set_and_collation(table_name, instance.get_session) + + +def correct_db_schema_precision( + instance: Recorder, + table_object: type[DeclarativeBase], + schema_errors: set[str], +) -> None: + """Correct precision issues detected by validate_db_schema.""" + table_name = table_object.__tablename__ + + if f"{table_name}.double precision" in schema_errors: + from ..migration import ( # pylint: disable=import-outside-toplevel + _modify_columns, + ) + + precision_columns = _get_precision_column_types(table_object) + # Attempt to convert timestamp columns to µs precision + session_maker = instance.get_session + engine = instance.engine + assert engine is not None, "Engine should be set" + _modify_columns( + session_maker, + engine, + table_name, + [f"{column} {DOUBLE_PRECISION_TYPE_SQL}" for column in precision_columns], + ) diff --git a/homeassistant/components/recorder/auto_repairs/states/schema.py b/homeassistant/components/recorder/auto_repairs/states/schema.py new file mode 100644 index 000000000000..258e15cbb521 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/states/schema.py @@ -0,0 +1,39 @@ +"""States schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import StateAttributes, States +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + +TABLE_UTF8_COLUMNS = { + States: (States.state,), + StateAttributes: (StateAttributes.shared_attrs,), +} + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + schema_errors: set[str] = set() + for table, columns in TABLE_UTF8_COLUMNS.items(): + schema_errors |= validate_table_schema_supports_utf8(instance, table, columns) + schema_errors |= validate_db_schema_precision(instance, States) + return schema_errors + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + for table in (States, StateAttributes): + correct_db_schema_utf8(instance, table, schema_errors) + correct_db_schema_precision(instance, States, schema_errors) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/schema.py b/homeassistant/components/recorder/auto_repairs/statistics/schema.py index bbf59080ac19..9b4687cb72d7 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/schema.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/schema.py @@ -1,28 +1,16 @@ """Statistics schema repairs.""" from __future__ import annotations -from collections.abc import Callable, Mapping -import contextlib -from datetime import datetime import logging from typing import TYPE_CHECKING -from sqlalchemy import text -from sqlalchemy.engine import Engine -from sqlalchemy.exc import OperationalError, SQLAlchemyError -from sqlalchemy.orm.session import Session - -from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util - -from ...const import DOMAIN, SupportedDialect -from ...db_schema import Statistics, StatisticsShortTerm -from ...models import StatisticData, StatisticMetaData, datetime_to_timestamp_or_none -from ...statistics import ( - _import_statistics_with_session, - _statistics_during_period_with_session, +from ...db_schema import Statistics, StatisticsMeta, StatisticsShortTerm +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, ) -from ...util import session_scope if TYPE_CHECKING: from ... import Recorder @@ -30,200 +18,14 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -def _validate_db_schema_utf8( - instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: +def validate_db_schema(instance: Recorder) -> set[str]: """Do some basic checks for common schema errors caused by manual migration.""" schema_errors: set[str] = set() - - # Lack of full utf8 support is only an issue for MySQL / MariaDB - if instance.dialect_name != SupportedDialect.MYSQL: - return schema_errors - - # This name can't be represented unless 4-byte UTF-8 unicode is supported - utf8_name = "𓆚𓃗" - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": utf8_name, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics_meta_manager = instance.statistics_meta_manager - - # Try inserting some metadata which needs utf8mb4 support - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - old_metadata_dict = statistics_meta_manager.get_many( - session, statistic_ids={statistic_id} - ) - try: - statistics_meta_manager.update_or_add( - session, metadata, old_metadata_dict - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except OperationalError as err: - if err.orig and err.orig.args[0] == 1366: - _LOGGER.debug( - "Database table statistics_meta does not support 4-byte UTF-8" - ) - schema_errors.add("statistics_meta.4-byte UTF-8") - session.rollback() - else: - raise - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - return schema_errors - - -def _get_future_year() -> int: - """Get a year in the future.""" - return datetime.now().year + 1 - - -def _validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - statistics_meta_manager = instance.statistics_meta_manager - - # Wrong precision is only an issue for MySQL / MariaDB / PostgreSQL - if instance.dialect_name not in ( - SupportedDialect.MYSQL, - SupportedDialect.POSTGRESQL, - ): - return schema_errors - - # This number can't be accurately represented as a 32-bit float - precise_number = 1.000000000000001 - # This time can't be accurately represented unless datetimes have µs precision - # - # We want to insert statistics for a time in the future, in case they - # have conflicting metadata_id's with existing statistics that were - # never cleaned up. By inserting in the future, we can be sure that - # that by selecting the last inserted row, we will get the one we - # just inserted. - # - future_year = _get_future_year() - precise_time = datetime(future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - start_time = datetime(future_year, 10, 6, tzinfo=dt_util.UTC) - statistic_id = f"{DOMAIN}.db_test" - - metadata: StatisticMetaData = { - "has_mean": True, - "has_sum": True, - "name": None, - "source": DOMAIN, - "statistic_id": statistic_id, - "unit_of_measurement": None, - } - statistics: StatisticData = { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - - def check_columns( - schema_errors: set[str], - stored: Mapping, - expected: Mapping, - columns: tuple[str, ...], - table_name: str, - supports: str, - ) -> None: - for column in columns: - if stored[column] != expected[column]: - schema_errors.add(f"{table_name}.{supports}") - _LOGGER.error( - "Column %s in database table %s does not support %s (stored=%s != expected=%s)", - column, - table_name, - supports, - stored[column], - expected[column], - ) - - # Insert / adjust a test statistics row in each of the tables - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, + schema_errors |= validate_table_schema_supports_utf8( + instance, StatisticsMeta, (StatisticsMeta.statistic_id,) ) - try: - # Mark the session as read_only to ensure that the test data is not committed - # to the database and we always rollback when the scope is exited - with session_scope(session=session_maker(), read_only=True) as session: - for table in tables: - _import_statistics_with_session( - instance, session, metadata, (statistics,), table - ) - stored_statistics = _statistics_during_period_with_session( - hass, - session, - start_time, - None, - {statistic_id}, - "hour" if table == Statistics else "5minute", - None, - {"last_reset", "max", "mean", "min", "state", "sum"}, - ) - if not (stored_statistic := stored_statistics.get(statistic_id)): - _LOGGER.warning( - "Schema validation failed for table: %s", table.__tablename__ - ) - continue - - # We want to look at the last inserted row to make sure there - # is not previous garbage data in the table that would cause - # the test to produce an incorrect result. To achieve this, - # we inserted a row in the future, and now we select the last - # inserted row back. - last_stored_statistic = stored_statistic[-1] - check_columns( - schema_errors, - last_stored_statistic, - statistics, - ("max", "mean", "min", "state", "sum"), - table.__tablename__, - "double precision", - ) - assert statistics["last_reset"] - check_columns( - schema_errors, - last_stored_statistic, - { - "last_reset": datetime_to_timestamp_or_none( - statistics["last_reset"] - ), - "start": datetime_to_timestamp_or_none(statistics["start"]), - }, - ("start", "last_reset"), - table.__tablename__, - "µs precision", - ) - statistics_meta_manager.delete(session, statistic_ids=[statistic_id]) - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception("Error when validating DB schema: %s", exc) - - return schema_errors - - -def validate_db_schema( - hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] -) -> set[str]: - """Do some basic checks for common schema errors caused by manual migration.""" - schema_errors: set[str] = set() - schema_errors |= _validate_db_schema_utf8(instance, session_maker) - schema_errors |= _validate_db_schema(hass, instance, session_maker) + for table in (Statistics, StatisticsShortTerm): + schema_errors |= validate_db_schema_precision(instance, table) if schema_errors: _LOGGER.debug( "Detected statistics schema errors: %s", ", ".join(sorted(schema_errors)) @@ -233,63 +35,9 @@ def validate_db_schema( def correct_db_schema( instance: Recorder, - engine: Engine, - session_maker: Callable[[], Session], schema_errors: set[str], ) -> None: """Correct issues detected by validate_db_schema.""" - from ...migration import _modify_columns # pylint: disable=import-outside-toplevel - - if "statistics_meta.4-byte UTF-8" in schema_errors: - # Attempt to convert the table to utf8mb4 - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4. " - "Note: this can take several minutes on large databases and slow " - "computers. Please be patient!" - ), - "statistics_meta", - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - "ALTER TABLE statistics_meta CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) - - tables: tuple[type[Statistics | StatisticsShortTerm], ...] = ( - Statistics, - StatisticsShortTerm, - ) - for table in tables: - if f"{table.__tablename__}.double precision" in schema_errors: - # Attempt to convert float columns to double precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", - ], - ) - if f"{table.__tablename__}.µs precision" in schema_errors: - # Attempt to convert timestamp columns to µs precision - _modify_columns( - session_maker, - engine, - table.__tablename__, - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ) + correct_db_schema_utf8(instance, StatisticsMeta, schema_errors) + for table in (Statistics, StatisticsShortTerm): + correct_db_schema_precision(instance, table, schema_errors) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 0bb0b846a3fe..cf4c0543c12f 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -119,13 +119,17 @@ STATES_CONTEXT_ID_BIN_INDEX = "ix_states_context_id_bin" LEGACY_STATES_EVENT_ID_INDEX = "ix_states_event_id" CONTEXT_ID_BIN_MAX_LENGTH = 16 +MYSQL_COLLATE = "utf8mb4_unicode_ci" +MYSQL_DEFAULT_CHARSET = "utf8mb4" +MYSQL_ENGINE = "InnoDB" + _DEFAULT_TABLE_ARGS = { - "mysql_default_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - "mysql_engine": "InnoDB", - "mariadb_default_charset": "utf8mb4", - "mariadb_collate": "utf8mb4_unicode_ci", - "mariadb_engine": "InnoDB", + "mysql_default_charset": MYSQL_DEFAULT_CHARSET, + "mysql_collate": MYSQL_COLLATE, + "mysql_engine": MYSQL_ENGINE, + "mariadb_default_charset": MYSQL_DEFAULT_CHARSET, + "mariadb_collate": MYSQL_COLLATE, + "mariadb_engine": MYSQL_ENGINE, } @@ -154,6 +158,7 @@ DOUBLE_TYPE = ( .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) +DOUBLE_PRECISION_TYPE_SQL = "DOUBLE PRECISION" TIMESTAMP_TYPE = DOUBLE_TYPE diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 6fc2138d918f..927097b18fd2 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -28,6 +28,10 @@ from homeassistant.core import HomeAssistant from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes +from .auto_repairs.states.schema import ( + correct_db_schema as states_correct_db_schema, + validate_db_schema as states_validate_db_schema, +) from .auto_repairs.statistics.duplicates import ( delete_statistics_duplicates, delete_statistics_meta_duplicates, @@ -39,7 +43,10 @@ from .auto_repairs.statistics.schema import ( from .const import SupportedDialect from .db_schema import ( CONTEXT_ID_BIN_MAX_LENGTH, + DOUBLE_PRECISION_TYPE_SQL, LEGACY_STATES_EVENT_ID_INDEX, + MYSQL_COLLATE, + MYSQL_DEFAULT_CHARSET, SCHEMA_VERSION, STATISTICS_TABLES, TABLE_STATES, @@ -96,13 +103,13 @@ class _ColumnTypesForDialect: _MYSQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER(20)", - timestamp_type="DOUBLE PRECISION", + timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})", ) _POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect( big_int_type="INTEGER", - timestamp_type="DOUBLE PRECISION", + timestamp_type=DOUBLE_PRECISION_TYPE_SQL, context_bin_type="BYTEA", ) @@ -151,7 +158,7 @@ class SchemaValidationStatus: """Store schema validation status.""" current_version: int - statistics_schema_errors: set[str] + schema_errors: set[str] valid: bool @@ -178,13 +185,23 @@ def validate_db_schema( if is_current := _schema_is_current(current_version): # We can only check for further errors if the schema is current, because # columns may otherwise not exist etc. - schema_errors |= statistics_validate_db_schema(hass, instance, session_maker) + schema_errors = _find_schema_errors(hass, instance, session_maker) valid = is_current and not schema_errors return SchemaValidationStatus(current_version, schema_errors, valid) +def _find_schema_errors( + hass: HomeAssistant, instance: Recorder, session_maker: Callable[[], Session] +) -> set[str]: + """Find schema errors.""" + schema_errors: set[str] = set() + schema_errors |= statistics_validate_db_schema(instance) + schema_errors |= states_validate_db_schema(instance) + return schema_errors + + def live_migration(schema_status: SchemaValidationStatus) -> bool: """Check if live migration is possible.""" return schema_status.current_version >= LIVE_MIGRATION_MIN_SCHEMA_VERSION @@ -226,12 +243,13 @@ def migrate_schema( # so its clear that the upgrade is done _LOGGER.warning("Upgrade to version %s done", new_version) - if schema_errors := schema_status.statistics_schema_errors: + if schema_errors := schema_status.schema_errors: _LOGGER.warning( "Database is about to correct DB schema errors: %s", ", ".join(sorted(schema_errors)), ) - statistics_correct_db_schema(instance, engine, session_maker, schema_errors) + statistics_correct_db_schema(instance, schema_errors) + states_correct_db_schema(instance, schema_errors) if current_version != SCHEMA_VERSION: instance.queue_task(PostSchemaMigrationTask(current_version, SCHEMA_VERSION)) @@ -732,38 +750,15 @@ def _apply_update( # noqa: C901 engine, "statistics", [ - "mean DOUBLE PRECISION", - "min DOUBLE PRECISION", - "max DOUBLE PRECISION", - "state DOUBLE PRECISION", - "sum DOUBLE PRECISION", + f"{column} {DOUBLE_PRECISION_TYPE_SQL}" + for column in ("max", "mean", "min", "state", "sum") ], ) elif new_version == 21: # Try to change the character set of the statistic_meta table if engine.dialect.name == SupportedDialect.MYSQL: for table in ("events", "states", "statistics_meta"): - _LOGGER.warning( - ( - "Updating character set and collation of table %s to utf8mb4." - " Note: this can take several minutes on large databases and" - " slow computers. Please be patient!" - ), - table, - ) - with contextlib.suppress(SQLAlchemyError), session_scope( - session=session_maker() - ) as session: - connection = session.connection() - connection.execute( - # Using LOCK=EXCLUSIVE to prevent - # the database from corrupting - # https://github.com/home-assistant/core/issues/56104 - text( - f"ALTER TABLE {table} CONVERT TO CHARACTER SET utf8mb4" - " COLLATE utf8mb4_unicode_ci, LOCK=EXCLUSIVE" - ) - ) + _correct_table_character_set_and_collation(table, session_maker) elif new_version == 22: # Recreate the all statistics tables for Oracle DB with Identity columns # @@ -1090,6 +1085,33 @@ def _apply_update( # noqa: C901 raise ValueError(f"No schema migration defined for version {new_version}") +def _correct_table_character_set_and_collation( + table: str, + session_maker: Callable[[], Session], +) -> None: + """Correct issues detected by validate_db_schema.""" + # Attempt to convert the table to utf8mb4 + _LOGGER.warning( + "Updating character set and collation of table %s to utf8mb4. " + "Note: this can take several minutes on large databases and slow " + "computers. Please be patient!", + table, + ) + with contextlib.suppress(SQLAlchemyError), session_scope( + session=session_maker() + ) as session: + connection = session.connection() + connection.execute( + # Using LOCK=EXCLUSIVE to prevent the database from corrupting + # https://github.com/home-assistant/core/issues/56104 + text( + f"ALTER TABLE {table} CONVERT TO CHARACTER SET " + f"{MYSQL_DEFAULT_CHARSET} " + f"COLLATE {MYSQL_COLLATE}, LOCK=EXCLUSIVE" + ) + ) + + def post_schema_migration( instance: Recorder, old_version: int, diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 9430158fae90..6394f8422260 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -102,7 +102,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "enable_custom_integrations": "None", "enable_nightly_purge": "bool", "enable_statistics": "bool", - "enable_statistics_table_validation": "bool", + "enable_schema_validation": "bool", "entity_registry": "EntityRegistry", "freezer": "FrozenDateTimeFactory", "hass_access_token": "str", diff --git a/tests/components/recorder/auto_repairs/states/__init__.py b/tests/components/recorder/auto_repairs/states/__init__.py new file mode 100644 index 000000000000..6e98d881ea9c --- /dev/null +++ b/tests/components/recorder/auto_repairs/states/__init__.py @@ -0,0 +1,5 @@ +"""Tests for Recorder component.""" + +import pytest + +pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/states/test_schema.py b/tests/components/recorder/auto_repairs/states/test_schema.py new file mode 100644 index 000000000000..2e37001582e9 --- /dev/null +++ b/tests/components/recorder/auto_repairs/states/test_schema.py @@ -0,0 +1,106 @@ +"""The test repairing states schema.""" + +# pylint: disable=invalid-name +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from ...common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema_fix_float_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with postgresql and mysql. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", + return_value={"states.double precision"}, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: states.double precision" + in caplog.text + ) + modification = [ + "last_changed_ts DOUBLE PRECISION", + "last_updated_ts DOUBLE PRECISION", + ] + modify_columns_mock.assert_called_once_with(ANY, ANY, "states", modification) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue_states( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"states.4-byte UTF-8"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: states.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table states to utf8mb4" in caplog.text + ) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue_state_attributes( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"state_attributes.4-byte UTF-8"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: state_attributes.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table state_attributes to utf8mb4" + in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/statistics/test_schema.py b/tests/components/recorder/auto_repairs/statistics/test_schema.py index 2c4e06580f5f..dfe036355aa7 100644 --- a/tests/components/recorder/auto_repairs/statistics/test_schema.py +++ b/tests/components/recorder/auto_repairs/statistics/test_schema.py @@ -1,52 +1,18 @@ """The test repairing statistics schema.""" # pylint: disable=invalid-name -from datetime import datetime -from unittest.mock import ANY, DEFAULT, MagicMock, patch +from unittest.mock import ANY, patch import pytest -from sqlalchemy.exc import OperationalError -from homeassistant.components.recorder.auto_repairs.statistics.schema import ( - _get_future_year, -) -from homeassistant.components.recorder.statistics import ( - _statistics_during_period_with_session, -) -from homeassistant.components.recorder.table_managers.statistics_meta import ( - StatisticsMetaManager, -) from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util from ...common import async_wait_recording_done from tests.typing import RecorderInstanceGenerator -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -async def test_validate_db_schema( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, -) -> None: - """Test validating DB schema with MySQL and PostgreSQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ): - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - assert "Schema validation failed" not in caplog.text - assert "Detected statistics schema errors" not in caplog.text - assert "Database is about to correct DB schema errors" not in caplog.text - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("enable_schema_validation", [True]) async def test_validate_db_schema_fix_utf8_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, @@ -56,15 +22,11 @@ async def test_validate_db_schema_fix_utf8_issue( Note: The test uses SQLite, the purpose is only to exercise the code. """ - orig_error = MagicMock() - orig_error.args = [1366] - utf8_error = OperationalError("", "", orig=orig_error) with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" ), patch( - "homeassistant.components.recorder.table_managers.statistics_meta.StatisticsMetaManager.update_or_add", - wraps=StatisticsMetaManager.update_or_add, - side_effect=[utf8_error, DEFAULT, DEFAULT], + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"statistics_meta.4-byte UTF-8"}, ): await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) @@ -80,60 +42,25 @@ async def test_validate_db_schema_fix_utf8_issue( ) -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("table", ("statistics_short_term", "statistics")) @pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - (("max", 1.0), ("mean", 1.0), ("min", 1.0), ("state", 1.0), ("sum", 1.0)), -) async def test_validate_db_schema_fix_float_issue( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - db_engine, - table, - replace_index, - column, - value, + table: str, + db_engine: str, ) -> None: - """Test validating DB schema with MySQL. + """Test validating DB schema with postgresql and mysql. Note: The test uses SQLite, the purpose is only to exercise the code. """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - fixed_future_year = _get_future_year() - precise_time = datetime(fixed_future_year, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time.timestamp(), - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time.timestamp(), - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - with patch( "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine ), patch( - "homeassistant.components.recorder.auto_repairs.statistics.schema._get_future_year", - return_value=fixed_future_year, - ), patch( - "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, + "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", + return_value={f"{table}.double precision"}, ), patch( "homeassistant.components.recorder.migration._modify_columns" ) as modify_columns_mock: @@ -146,90 +73,13 @@ async def test_validate_db_schema_fix_float_issue( in caplog.text ) modification = [ + "created_ts DOUBLE PRECISION", + "start_ts DOUBLE PRECISION", "mean DOUBLE PRECISION", "min DOUBLE PRECISION", "max DOUBLE PRECISION", + "last_reset_ts DOUBLE PRECISION", "state DOUBLE PRECISION", "sum DOUBLE PRECISION", ] modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) - - -@pytest.mark.parametrize("enable_statistics_table_validation", [True]) -@pytest.mark.parametrize( - ("db_engine", "modification"), - ( - ("mysql", ["last_reset_ts DOUBLE PRECISION", "start_ts DOUBLE PRECISION"]), - ( - "postgresql", - [ - "last_reset_ts DOUBLE PRECISION", - "start_ts DOUBLE PRECISION", - ], - ), - ), -) -@pytest.mark.parametrize( - ("table", "replace_index"), (("statistics", 0), ("statistics_short_term", 1)) -) -@pytest.mark.parametrize( - ("column", "value"), - ( - ("last_reset", "2020-10-06T00:00:00+00:00"), - ("start", "2020-10-06T00:00:00+00:00"), - ), -) -async def test_validate_db_schema_fix_statistics_datetime_issue( - async_setup_recorder_instance: RecorderInstanceGenerator, - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - db_engine, - modification, - table, - replace_index, - column, - value, -) -> None: - """Test validating DB schema with MySQL. - - Note: The test uses SQLite, the purpose is only to exercise the code. - """ - orig_error = MagicMock() - orig_error.args = [1366] - precise_number = 1.000000000000001 - precise_time = datetime(2020, 10, 6, microsecond=1, tzinfo=dt_util.UTC) - statistics = { - "recorder.db_test": [ - { - "last_reset": precise_time, - "max": precise_number, - "mean": precise_number, - "min": precise_number, - "start": precise_time, - "state": precise_number, - "sum": precise_number, - } - ] - } - statistics["recorder.db_test"][0][column] = value - fake_statistics = [DEFAULT, DEFAULT] - fake_statistics[replace_index] = statistics - - with patch( - "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine - ), patch( - "homeassistant.components.recorder.auto_repairs.statistics.schema._statistics_during_period_with_session", - side_effect=fake_statistics, - wraps=_statistics_during_period_with_session, - ), patch( - "homeassistant.components.recorder.migration._modify_columns" - ) as modify_columns_mock: - await async_setup_recorder_instance(hass) - await async_wait_recording_done(hass) - - assert "Schema validation failed" not in caplog.text - assert ( - f"Database is about to correct DB schema errors: {table}.µs precision" - in caplog.text - ) - modify_columns_mock.assert_called_once_with(ANY, ANY, table, modification) diff --git a/tests/components/recorder/auto_repairs/test_schema.py b/tests/components/recorder/auto_repairs/test_schema.py new file mode 100644 index 000000000000..510f46f98a21 --- /dev/null +++ b/tests/components/recorder/auto_repairs/test_schema.py @@ -0,0 +1,253 @@ +"""The test validating and repairing schema.""" + +# pylint: disable=invalid-name +from unittest.mock import patch + +import pytest +from sqlalchemy import text + +from homeassistant.components.recorder.auto_repairs.schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) +from homeassistant.components.recorder.db_schema import States +from homeassistant.components.recorder.migration import _modify_columns +from homeassistant.components.recorder.util import get_instance, session_scope +from homeassistant.core import HomeAssistant + +from ..common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with MySQL and PostgreSQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + assert "Schema validation failed" not in caplog.text + assert "Detected statistics schema errors" not in caplog.text + assert "Database is about to correct DB schema errors" not in caplog.text + + +async def test_validate_db_schema_fix_utf8_issue_good_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the schema is correct.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, (States.state,) + ) + assert schema_errors == set() + + +async def test_validate_db_schema_fix_utf8_issue_with_broken_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the schema is broken and repairing it.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + with session_scope(session=session_maker()) as session: + session.execute( + text( + "ALTER TABLE states MODIFY state VARCHAR(255) " + "CHARACTER SET ascii COLLATE ascii_general_ci, " + "LOCK=EXCLUSIVE;" + ) + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, (States.state,) + ) + assert schema_errors == {"states.4-byte UTF-8"} + + # Now repair the schema + await instance.async_add_executor_job( + correct_db_schema_utf8, instance, States, schema_errors + ) + + # Now validate the schema again + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, ("state",) + ) + assert schema_errors == set() + + +async def test_validate_db_schema_fix_utf8_issue_with_broken_schema_unrepairable( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL when the schema is broken and cannot be repaired.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + with session_scope(session=session_maker()) as session: + session.execute( + text( + "ALTER TABLE states MODIFY state VARCHAR(255) " + "CHARACTER SET ascii COLLATE ascii_general_ci, " + "LOCK=EXCLUSIVE;" + ) + ) + _modify_columns( + session_maker, + instance.engine, + "states", + [ + "entity_id VARCHAR(255) NOT NULL", + ], + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_table_schema_supports_utf8, instance, States, ("state",) + ) + assert schema_errors == set() + assert "Error when validating DB schema" in caplog.text + + +async def test_validate_db_schema_precision_good_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is correct.""" + if not recorder_db_url.startswith(("mysql://", "postgresql://")): + # This problem only happens on MySQL and PostgreSQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert schema_errors == set() + + +async def test_validate_db_schema_precision_with_broken_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is broken and than repair it.""" + if not recorder_db_url.startswith(("mysql://", "postgresql://")): + # This problem only happens on MySQL and PostgreSQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + _modify_columns( + session_maker, + instance.engine, + "states", + [ + "last_updated_ts FLOAT(4)", + "last_changed_ts FLOAT(4)", + ], + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert schema_errors == {"states.double precision"} + + # Now repair the schema + await instance.async_add_executor_job( + correct_db_schema_precision, instance, States, schema_errors + ) + + # Now validate the schema again + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert schema_errors == set() + + +async def test_validate_db_schema_precision_with_unrepairable_broken_schema( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + recorder_db_url: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema when the schema is broken and cannot be repaired.""" + if not recorder_db_url.startswith("mysql://"): + # This problem only happens on MySQL + return + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + instance = get_instance(hass) + session_maker = instance.get_session + + def _break_states_schema(): + _modify_columns( + session_maker, + instance.engine, + "states", + [ + "state VARCHAR(255) NOT NULL", + "last_updated_ts FLOAT(4)", + "last_changed_ts FLOAT(4)", + ], + ) + + await instance.async_add_executor_job(_break_states_schema) + schema_errors = await instance.async_add_executor_job( + validate_db_schema_precision, + instance, + States, + ) + assert "Error when validating DB schema" in caplog.text + assert schema_errors == set() diff --git a/tests/conftest.py b/tests/conftest.py index c5197dd2bd26..397d3d55b29c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1161,11 +1161,11 @@ def enable_statistics() -> bool: @pytest.fixture -def enable_statistics_table_validation() -> bool: +def enable_schema_validation() -> bool: """Fixture to control enabling of recorder's statistics table validation. To enable statistics table validation, tests can be marked with: - @pytest.mark.parametrize("enable_statistics_table_validation", [True]) + @pytest.mark.parametrize("enable_schema_validation", [True]) """ return False @@ -1272,7 +1272,7 @@ def hass_recorder( recorder_db_url: str, enable_nightly_purge: bool, enable_statistics: bool, - enable_statistics_table_validation: bool, + enable_schema_validation: bool, enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, @@ -1283,16 +1283,16 @@ def hass_recorder( from homeassistant.components import recorder # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder.auto_repairs.statistics import schema + from homeassistant.components.recorder import migration original_tz = dt_util.DEFAULT_TIME_ZONE hass = get_test_home_assistant() nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None - stats_validate = ( - schema.validate_db_schema - if enable_statistics_table_validation + schema_validate = ( + migration._find_schema_errors + if enable_schema_validation else itertools.repeat(set()) ) migrate_states_context_ids = ( @@ -1322,8 +1322,8 @@ def hass_recorder( side_effect=stats, autospec=True, ), patch( - "homeassistant.components.recorder.migration.statistics_validate_db_schema", - side_effect=stats_validate, + "homeassistant.components.recorder.migration._find_schema_errors", + side_effect=schema_validate, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", @@ -1391,7 +1391,7 @@ async def async_setup_recorder_instance( recorder_db_url: str, enable_nightly_purge: bool, enable_statistics: bool, - enable_statistics_table_validation: bool, + enable_schema_validation: bool, enable_migrate_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, @@ -1401,16 +1401,16 @@ async def async_setup_recorder_instance( from homeassistant.components import recorder # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder.auto_repairs.statistics import schema + from homeassistant.components.recorder import migration # pylint: disable-next=import-outside-toplevel from .components.recorder.common import async_recorder_block_till_done nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None - stats_validate = ( - schema.validate_db_schema - if enable_statistics_table_validation + schema_validate = ( + migration._find_schema_errors + if enable_schema_validation else itertools.repeat(set()) ) migrate_states_context_ids = ( @@ -1440,8 +1440,8 @@ async def async_setup_recorder_instance( side_effect=stats, autospec=True, ), patch( - "homeassistant.components.recorder.migration.statistics_validate_db_schema", - side_effect=stats_validate, + "homeassistant.components.recorder.migration._find_schema_errors", + side_effect=schema_validate, autospec=True, ), patch( "homeassistant.components.recorder.Recorder._migrate_events_context_ids", From 03aeaba7ef6fc0093cca1d9e9984796b0d473ad4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 22 Mar 2023 22:34:23 +0100 Subject: [PATCH 0691/1058] Turn AVM FRITZ!Box Tools sensors into coordinator entities (#89953) * make sensors coordinator entities * apply suggestions * move _attr_has_entity_name up --- homeassistant/components/fritz/common.py | 85 +++++++++++++++++++++++- homeassistant/components/fritz/sensor.py | 53 ++++----------- 2 files changed, 95 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 09103a0bcc84..f6025e773e05 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -35,7 +35,8 @@ from homeassistant.helpers import ( update_coordinator, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( @@ -136,7 +137,9 @@ class HostInfo(TypedDict): status: bool -class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): +class FritzBoxTools( + update_coordinator.DataUpdateCoordinator[dict[str, bool | StateType]] +): """FritzBoxTools class.""" def __init__( @@ -175,6 +178,9 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): self._latest_firmware: str | None = None self._update_available: bool = False self._release_url: str | None = None + self._entity_update_functions: dict[ + str, Callable[[FritzStatus, StateType], Any] + ] = {} async def async_setup( self, options: MappingProxyType[str, Any] | None = None @@ -237,12 +243,36 @@ class FritzBoxTools(update_coordinator.DataUpdateCoordinator[None]): ) self.device_is_router = self.fritz_status.has_wan_enabled - async def _async_update_data(self) -> None: + def register_entity_updates( + self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] + ) -> Callable[[], None]: + """Register an entity to be updated by coordinator.""" + + def unregister_entity_updates() -> None: + """Unregister an entity to be updated by coordinator.""" + if key in self._entity_update_functions: + _LOGGER.debug("unregister entity %s from updates", key) + self._entity_update_functions.pop(key) + + if key not in self._entity_update_functions: + _LOGGER.debug("register entity %s for updates", key) + self._entity_update_functions[key] = update_fn + return unregister_entity_updates + + async def _async_update_data(self) -> dict[str, bool | StateType]: """Update FritzboxTools data.""" + enity_data: dict[str, bool | StateType] = {} try: await self.async_scan_devices() + for key, update_fn in self._entity_update_functions.items(): + _LOGGER.debug("update entity %s", key) + enity_data[key] = await self.hass.async_add_executor_job( + update_fn, self.fritz_status, self.data.get(key) + ) except FRITZ_EXCEPTIONS as ex: raise update_coordinator.UpdateFailed(ex) from ex + _LOGGER.debug("enity_data: %s", enity_data) + return enity_data @property def unique_id(self) -> str: @@ -981,6 +1011,55 @@ class FritzBoxBaseEntity: ) +@dataclass +class FritzRequireKeysMixin: + """Fritz entity description mix in.""" + + value_fn: Callable[[FritzStatus, Any], Any] + + +@dataclass +class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): + """Fritz entity base description.""" + + +class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity): + """Fritz host coordinator entity base class.""" + + coordinator: AvmWrapper + entity_description: FritzEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + avm_wrapper: AvmWrapper, + device_name: str, + description: FritzEntityDescription, + ) -> None: + """Init device info class.""" + super().__init__(avm_wrapper) + self.async_on_remove( + avm_wrapper.register_entity_updates(description.key, description.value_fn) + ) + self.entity_description = description + self._device_name = device_name + self._attr_name = description.name + self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + configuration_url=f"http://{self.coordinator.host}", + connections={(dr.CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer="AVM", + model=self.coordinator.model, + name=self._device_name, + sw_version=self.coordinator.current_firmware, + ) + + @dataclass class ConnectionInfo: """Fritz sensor connection information class.""" diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 628d56dc4508..4b15f3f92de3 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -5,9 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta import logging -from typing import Any -from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus from homeassistant.components.sensor import ( @@ -25,9 +23,15 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity +from .common import ( + AvmWrapper, + ConnectionInfo, + FritzBoxBaseCoordinatorEntity, + FritzEntityDescription, +) from .const import DOMAIN, DSL_CONNECTION, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) @@ -139,14 +143,7 @@ def _retrieve_link_attenuation_received_state( @dataclass -class FritzRequireKeysMixin: - """Fritz sensor data class.""" - - value_fn: Callable[[FritzStatus, Any], Any] - - -@dataclass -class FritzSensorEntityDescription(SensorEntityDescription, FritzRequireKeysMixin): +class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled @@ -304,36 +301,12 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): +class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): """Define FRITZ!Box connectivity class.""" entity_description: FritzSensorEntityDescription - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: FritzSensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._last_device_value: str | None = None - self._attr_available = True - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) - - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box sensors") - - status: FritzStatus = self._avm_wrapper.fritz_status - try: - self._attr_native_value = ( - self._last_device_value - ) = self.entity_description.value_fn(status, self._last_device_value) - except FritzConnectionException: - _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._attr_available = False - return - self._attr_available = True + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data.get(self.entity_description.key) From 99b58f157ec9e88fe6e97db1772631f9b5da90e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 14:00:47 -1000 Subject: [PATCH 0692/1058] Bump PyJWT to 2.6.0 (#90134) * Bump PyJWT to 2.6.0 * fix time being frozen too late which makes the access token creation time in the future * revert zha change * fix repairs test * fix ical test --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/alexa/test_smart_home.py | 25 +++---- .../bmw_connected_drive/test_diagnostics.py | 8 +- tests/components/ipma/test_weather.py | 4 +- .../local_calendar/test_diagnostics.py | 74 ++++++++++++++++--- tests/components/metoffice/test_init.py | 5 +- tests/components/metoffice/test_sensor.py | 10 ++- tests/components/metoffice/test_weather.py | 18 +++-- tests/components/recorder/test_util.py | 3 +- .../components/recorder/test_websocket_api.py | 12 ++- tests/components/repairs/test_init.py | 8 +- .../components/repairs/test_websocket_api.py | 3 +- tests/components/shelly/test_utils.py | 3 +- tests/components/tod/test_binary_sensor.py | 23 +++--- tests/components/tod/test_config_flow.py | 3 +- tests/components/tod/test_init.py | 5 +- .../unifiprotect/test_media_source.py | 7 +- 19 files changed, 143 insertions(+), 74 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ffef59913e00..0fcae3ec80ca 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -PyJWT==2.5.0 +PyJWT==2.6.0 PyNaCl==1.5.0 aiodiscover==1.4.14 aiohttp==3.8.4 diff --git a/pyproject.toml b/pyproject.toml index d8ba8e747545..5d39a99c0325 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "ifaddr==0.1.7", "jinja2==3.1.2", "lru-dict==1.1.8", - "PyJWT==2.5.0", + "PyJWT==2.6.0", # PyJWT has loose dependency. We want the latest one. "cryptography==39.0.1", # pyOpenSSL 23.0.0 is required to work with cryptography 39+ diff --git a/requirements.txt b/requirements.txt index 1b4874e2c4c7..2386015c8444 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ home-assistant-bluetooth==1.9.3 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 -PyJWT==2.5.0 +PyJWT==2.6.0 cryptography==39.0.1 pyOpenSSL==23.0.0 orjson==3.8.7 diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 49c6dc35ff18..601f59fd1187 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,7 +1,6 @@ """Test for smart home alexa support.""" from unittest.mock import patch -from freezegun import freeze_time import pytest from homeassistant.components.alexa import messages, smart_home @@ -158,7 +157,7 @@ def assert_endpoint_capabilities(endpoint, *interfaces): return capabilities -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_switch(hass: HomeAssistant, events: list[Event]) -> None: """Test switch discovery.""" device = ("switch.test", "on", {"friendly_name": "Test switch"}) @@ -212,7 +211,7 @@ async def test_outlet(hass: HomeAssistant, events: list[Event]) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_light(hass: HomeAssistant) -> None: """Test light discovery.""" device = ("light.test_1", "on", {"friendly_name": "Test light 1"}) @@ -308,7 +307,7 @@ async def test_color_light( # tests -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_script(hass: HomeAssistant) -> None: """Test script discovery.""" device = ("script.test", "off", {"friendly_name": "Test script"}) @@ -329,7 +328,7 @@ async def test_script(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_input_boolean(hass: HomeAssistant) -> None: """Test input boolean discovery.""" device = ("input_boolean.test", "off", {"friendly_name": "Test input boolean"}) @@ -366,7 +365,7 @@ async def test_input_boolean(hass: HomeAssistant) -> None: assert {"name": "detectionState"} in properties["supported"] -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_scene(hass: HomeAssistant) -> None: """Test scene discovery.""" device = ("scene.test", "off", {"friendly_name": "Test scene"}) @@ -387,7 +386,7 @@ async def test_scene(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_fan(hass: HomeAssistant) -> None: """Test fan discovery.""" device = ("fan.test_1", "off", {"friendly_name": "Test fan 1"}) @@ -945,7 +944,7 @@ async def test_single_preset_mode_fan( caplog.clear() -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_humidifier( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1117,7 +1116,7 @@ async def test_lock(hass: HomeAssistant) -> None: assert properties["value"] == "UNLOCKED" -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_media_player(hass: HomeAssistant) -> None: """Test media player discovery.""" device = ( @@ -1729,7 +1728,7 @@ async def test_media_player_seek_error(hass: HomeAssistant) -> None: assert msg["payload"]["type"] == "ACTION_NOT_PERMITTED_FOR_CONTENT" -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_alert(hass: HomeAssistant) -> None: """Test alert discovery.""" device = ("alert.test", "off", {"friendly_name": "Test alert"}) @@ -1747,7 +1746,7 @@ async def test_alert(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_automation(hass: HomeAssistant) -> None: """Test automation discovery.""" device = ("automation.test", "off", {"friendly_name": "Test automation"}) @@ -1769,7 +1768,7 @@ async def test_automation(hass: HomeAssistant) -> None: ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_group(hass: HomeAssistant) -> None: """Test group discovery.""" device = ("group.test", "off", {"friendly_name": "Test group"}) @@ -4183,7 +4182,7 @@ async def test_initialize_camera_stream( ) -@freeze_time("2022-04-19 07:53:05") +@pytest.mark.freeze_time("2022-04-19 07:53:05") @pytest.mark.parametrize( "domain", ["button", "input_button"], diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index 5858ae2e529d..a186a52bcd86 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -4,7 +4,7 @@ import json import os import time -from freezegun import freeze_time +import pytest from homeassistant.components.bmw_connected_drive.const import DOMAIN from homeassistant.core import HomeAssistant @@ -20,7 +20,7 @@ from tests.components.diagnostics import ( from tests.typing import ClientSessionGenerator -@freeze_time(datetime.datetime(2022, 7, 10, 11)) +@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture ) -> None: @@ -43,7 +43,7 @@ async def test_config_entry_diagnostics( assert diagnostics == diagnostics_fixture -@freeze_time(datetime.datetime(2022, 7, 10, 11)) +@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture ) -> None: @@ -72,7 +72,7 @@ async def test_device_diagnostics( assert diagnostics == diagnostics_fixture -@freeze_time(datetime.datetime(2022, 7, 10, 11)) +@pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics_vehicle_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture ) -> None: diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index c5b5a1298fa7..285f7ceacb77 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -2,7 +2,7 @@ from datetime import datetime from unittest.mock import patch -from freezegun import freeze_time +import pytest from homeassistant.components.weather import ( ATTR_FORECAST, @@ -100,7 +100,7 @@ async def test_daily_forecast(hass: HomeAssistant) -> None: assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S" -@freeze_time("2020-01-14 23:00:00") +@pytest.mark.freeze_time("2020-01-14 23:00:00") async def test_hourly_forecast(hass: HomeAssistant) -> None: """Test for successfully getting daily forecast.""" with patch( diff --git a/tests/components/local_calendar/test_diagnostics.py b/tests/components/local_calendar/test_diagnostics.py index 8b033cf4fdb7..561f7588a510 100644 --- a/tests/components/local_calendar/test_diagnostics.py +++ b/tests/components/local_calendar/test_diagnostics.py @@ -1,19 +1,46 @@ """Tests for diagnostics platform of local calendar.""" +from aiohttp.test_utils import TestClient from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.auth.models import Credentials from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY, ClientFixture +from .conftest import TEST_ENTITY, Client, ClientFixture -from tests.common import MockConfigEntry +from tests.common import CLIENT_ID, MockConfigEntry, MockUser from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +async def generate_new_hass_access_token( + hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials +) -> str: + """Return an access token to access Home Assistant.""" + await hass.auth.async_link_user(hass_admin_user, hass_admin_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID, credential=hass_admin_credential + ) + return hass.auth.async_create_access_token(refresh_token) + + +def _get_test_client_generator( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, new_token: str +): + """Return a test client generator."".""" + + async def auth_client() -> TestClient: + return await aiohttp_client( + hass.http.app, headers={"Authorization": f"Bearer {new_token}"} + ) + + return auth_client + + @pytest.fixture(autouse=True) async def setup_diag(hass): """Set up diagnostics platform.""" @@ -24,12 +51,27 @@ async def setup_diag(hass): async def test_empty_calendar( hass: HomeAssistant, setup_integration: None, - hass_client: ClientSessionGenerator, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, config_entry: MockConfigEntry, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics against an empty calendar.""" - data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + # + # Ideally we would use pytest.mark.freeze_time before the fixtures, but that does not + # work with the ical library and freezegun because + # `TypeError: '<' not supported between instances of 'FakeDatetimeMeta' and 'FakeDateMeta'` + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + data = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) assert data == snapshot @@ -37,14 +79,26 @@ async def test_empty_calendar( async def test_api_date_time_event( hass: HomeAssistant, setup_integration: None, + hass_admin_user: MockUser, + hass_admin_credential: Credentials, config_entry: MockConfigEntry, - hass_client: ClientSessionGenerator, - ws_client: ClientFixture, + hass_ws_client: ClientFixture, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, snapshot: SnapshotAssertion, ) -> None: """Test an event with a start/end date time.""" - - client = await ws_client() + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + # + # Ideally we would use pytest.mark.freeze_time before the fixtures, but that does not + # work with the ical library and freezegun because + # `TypeError: '<' not supported between instances of 'FakeDatetimeMeta' and 'FakeDateMeta'` + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + client = Client(await hass_ws_client(hass, access_token=new_token)) await client.cmd_result( "create", { @@ -58,5 +112,7 @@ async def test_api_date_time_event( }, ) - data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + data = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) assert data == snapshot diff --git a/tests/components/metoffice/test_init.py b/tests/components/metoffice/test_init.py index 917c031edba8..f21f3a1b26fe 100644 --- a/tests/components/metoffice/test_init.py +++ b/tests/components/metoffice/test_init.py @@ -3,7 +3,6 @@ from __future__ import annotations import datetime -from freezegun import freeze_time import pytest import requests_mock @@ -16,7 +15,9 @@ from .const import DOMAIN, METOFFICE_CONFIG_WAVERTREE, TEST_COORDINATES_WAVERTRE from tests.common import MockConfigEntry -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) @pytest.mark.parametrize( ("old_unique_id", "new_unique_id", "migration_needed"), [ diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index d2e5d55355f5..28bf8eda9973 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -2,7 +2,7 @@ import datetime import json -from freezegun import freeze_time +import pytest import requests_mock from homeassistant.components.metoffice.const import ATTRIBUTION, DOMAIN @@ -24,7 +24,9 @@ from .const import ( from tests.common import MockConfigEntry, load_fixture -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_one_sensor_site_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -72,7 +74,9 @@ async def test_one_sensor_site_running( assert sensor.attributes.get("attribution") == ATTRIBUTION -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_two_sensor_sites_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index d386004129c8..0e5a934c7d0b 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -3,7 +3,7 @@ import datetime from datetime import timedelta import json -from freezegun import freeze_time +import pytest import requests_mock from homeassistant.components.metoffice.const import DOMAIN @@ -23,7 +23,9 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_site_cannot_connect( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -52,7 +54,9 @@ async def test_site_cannot_connect( assert sensor is None -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_site_cannot_update( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -100,7 +104,9 @@ async def test_site_cannot_update( assert weather.state == STATE_UNAVAILABLE -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_one_weather_site_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -183,7 +189,9 @@ async def test_one_weather_site_running( assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" -@freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) +) async def test_two_weather_sites_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 383a0838430b..4cc4f4b94a82 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -6,7 +6,6 @@ from pathlib import Path import sqlite3 from unittest.mock import MagicMock, Mock, patch -from freezegun import freeze_time import py import pytest from sqlalchemy import text @@ -934,7 +933,7 @@ def test_execute_stmt_lambda_element( assert rows == ["mock_row"] -@freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) +@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) async def test_resolve_period(hass: HomeAssistant) -> None: """Test statistic_during_period.""" diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 5244a33f0bcd..8e760b40100d 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -217,7 +217,9 @@ async def test_statistics_during_period( } -@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) +) @pytest.mark.parametrize("offset", (0, 1, 2)) async def test_statistic_during_period( recorder_mock: Recorder, @@ -632,7 +634,9 @@ async def test_statistic_during_period( } -@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) +) async def test_statistic_during_period_hole( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -795,7 +799,9 @@ async def test_statistic_during_period_hole( } -@freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc)) +@pytest.mark.freeze_time( + datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) +) @pytest.mark.parametrize( ("calendar_period", "start_time", "end_time"), ( diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index bae71e71e2ef..ce787ad00b8f 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -1,7 +1,6 @@ """Test the repairs websocket API.""" from unittest.mock import AsyncMock, Mock -from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -27,7 +26,7 @@ from tests.common import mock_platform from tests.typing import WebSocketGenerator -@freeze_time("2022-07-19 07:53:05") +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_create_update_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -166,7 +165,7 @@ async def test_create_issue_invalid_version( assert msg["result"] == {"issues": []} -@freeze_time("2022-07-19 07:53:05") +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_ignore_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -335,6 +334,7 @@ async def test_ignore_issue( } +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_delete_issue( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -487,7 +487,7 @@ async def test_non_compliant_platform( assert list(hass.data[DOMAIN]["platforms"].keys()) == ["fake_integration"] -@freeze_time("2022-07-21 08:22:00") +@pytest.mark.freeze_time("2022-07-21 08:22:00") async def test_sync_methods( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 4db5b6a9d18e..c82337b484f9 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -5,7 +5,6 @@ from http import HTTPStatus from typing import Any from unittest.mock import ANY, AsyncMock, Mock -from freezegun import freeze_time import pytest import voluptuous as vol @@ -430,7 +429,7 @@ async def test_step_unauth( assert resp.status == HTTPStatus.UNAUTHORIZED -@freeze_time("2022-07-19 07:53:05") +@pytest.mark.freeze_time("2022-07-19 07:53:05") async def test_list_issues( hass: HomeAssistant, hass_storage: dict[str, Any], hass_ws_client ) -> None: diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index b2be77609812..701e8b487cb7 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -1,5 +1,4 @@ """Tests for Shelly utils.""" -from freezegun import freeze_time import pytest from homeassistant.components.shelly.utils import ( @@ -150,7 +149,7 @@ async def test_get_block_device_sleep_period(settings, sleep_period) -> None: assert get_block_device_sleep_period(settings) == sleep_period -@freeze_time("2019-01-10 18:43:00+00:00") +@pytest.mark.freeze_time("2019-01-10 18:43:00+00:00") async def test_get_device_uptime() -> None: """Test block test get device uptime.""" assert get_device_uptime( diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index 0f0a1456459b..c1823c23f8be 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -1,7 +1,6 @@ """Test Times of the Day Binary Sensor.""" from datetime import datetime, timedelta -from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -67,7 +66,7 @@ async def test_setup_no_sensors(hass: HomeAssistant) -> None: ) -@freeze_time("2019-01-10 18:43:00-08:00") +@pytest.mark.freeze_time("2019-01-10 18:43:00-08:00") async def test_in_period_on_start(hass: HomeAssistant) -> None: """Test simple setting.""" config = { @@ -87,7 +86,7 @@ async def test_in_period_on_start(hass: HomeAssistant) -> None: assert state.state == STATE_ON -@freeze_time("2019-01-10 22:30:00-08:00") +@pytest.mark.freeze_time("2019-01-10 22:30:00-08:00") async def test_midnight_turnover_before_midnight_inside_period( hass: HomeAssistant, ) -> None: @@ -131,7 +130,7 @@ async def test_midnight_turnover_after_midnight_inside_period( assert state.state == STATE_ON -@freeze_time("2019-01-10 20:30:00-08:00") +@pytest.mark.freeze_time("2019-01-10 20:30:00-08:00") async def test_midnight_turnover_before_midnight_outside_period( hass: HomeAssistant, ) -> None: @@ -148,7 +147,7 @@ async def test_midnight_turnover_before_midnight_outside_period( assert state.state == STATE_OFF -@freeze_time("2019-01-10 10:00:00-08:00") +@pytest.mark.freeze_time("2019-01-10 10:00:00-08:00") async def test_after_happens_tomorrow(hass: HomeAssistant) -> None: """Test when both before and after are in the future, and after is later than before.""" config = { @@ -643,7 +642,7 @@ async def test_dst( assert state.state == STATE_OFF -@freeze_time("2019-01-10 18:43:00") +@pytest.mark.freeze_time("2019-01-10 18:43:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_not_in_range( hass: HomeAssistant, @@ -669,7 +668,7 @@ async def test_simple_before_after_does_not_loop_utc_not_in_range( assert state.attributes["next_update"] == "2019-01-10T22:00:00+00:00" -@freeze_time("2019-01-10 22:43:00") +@pytest.mark.freeze_time("2019-01-10 22:43:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_in_range( hass: HomeAssistant, @@ -695,7 +694,7 @@ async def test_simple_before_after_does_not_loop_utc_in_range( assert state.attributes["next_update"] == "2019-01-11T06:00:00+00:00" -@freeze_time("2019-01-11 06:00:00") +@pytest.mark.freeze_time("2019-01-11 06:00:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_fire_at_before( hass: HomeAssistant, @@ -721,7 +720,7 @@ async def test_simple_before_after_does_not_loop_utc_fire_at_before( assert state.attributes["next_update"] == "2019-01-11T22:00:00+00:00" -@freeze_time("2019-01-10 22:00:00") +@pytest.mark.freeze_time("2019-01-10 22:00:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_fire_at_after( hass: HomeAssistant, @@ -747,7 +746,7 @@ async def test_simple_before_after_does_not_loop_utc_fire_at_after( assert state.attributes["next_update"] == "2019-01-11T06:00:00+00:00" -@freeze_time("2019-01-10 22:00:00") +@pytest.mark.freeze_time("2019-01-10 22:00:00") @pytest.mark.parametrize("hass_time_zone", ("UTC",)) async def test_simple_before_after_does_not_loop_utc_both_before_now( hass: HomeAssistant, @@ -773,7 +772,7 @@ async def test_simple_before_after_does_not_loop_utc_both_before_now( assert state.attributes["next_update"] == "2019-01-11T00:00:00+00:00" -@freeze_time("2019-01-10 17:43:00+01:00") +@pytest.mark.freeze_time("2019-01-10 17:43:00+01:00") @pytest.mark.parametrize("hass_time_zone", ("Europe/Berlin",)) async def test_simple_before_after_does_not_loop_berlin_not_in_range( hass: HomeAssistant, @@ -799,7 +798,7 @@ async def test_simple_before_after_does_not_loop_berlin_not_in_range( assert state.attributes["next_update"] == "2019-01-11T00:00:00+01:00" -@freeze_time("2019-01-11 00:43:00+01:00") +@pytest.mark.freeze_time("2019-01-11 00:43:00+01:00") @pytest.mark.parametrize("hass_time_zone", ("Europe/Berlin",)) async def test_simple_before_after_does_not_loop_berlin_in_range( hass: HomeAssistant, diff --git a/tests/components/tod/test_config_flow.py b/tests/components/tod/test_config_flow.py index 4d0e2a061908..6860d401ce24 100644 --- a/tests/components/tod/test_config_flow.py +++ b/tests/components/tod/test_config_flow.py @@ -1,7 +1,6 @@ """Test the Times of the Day config flow.""" from unittest.mock import patch -from freezegun import freeze_time import pytest from homeassistant import config_entries @@ -66,7 +65,7 @@ def get_suggested(schema, key): raise Exception -@freeze_time("2022-03-16 17:37:00", tz_offset=-7) +@pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) async def test_options(hass: HomeAssistant) -> None: """Test reconfiguring.""" # Setup the config entry diff --git a/tests/components/tod/test_init.py b/tests/components/tod/test_init.py index 510bf848ad45..4a9f55bdec32 100644 --- a/tests/components/tod/test_init.py +++ b/tests/components/tod/test_init.py @@ -1,5 +1,6 @@ """Test the Times of the Day integration.""" -from freezegun import freeze_time + +import pytest from homeassistant.components.tod.const import DOMAIN from homeassistant.core import HomeAssistant @@ -8,7 +9,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -@freeze_time("2022-03-16 17:37:00", tz_offset=-7) +@pytest.mark.freeze_time("2022-03-16 17:37:00", tz_offset=-7) async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: """Test setting up and removing a config entry.""" registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 1df0fbb168ef..e19985aea3fb 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -4,7 +4,6 @@ from datetime import datetime, timedelta from ipaddress import IPv4Address from unittest.mock import AsyncMock, Mock, patch -from freezegun import freeze_time import pytest import pytz from pyunifiprotect.data import ( @@ -465,7 +464,7 @@ TWO_MONTH_SIMPLE = ( ("start", "months"), [ONE_MONTH_SIMPLE, TWO_MONTH_SIMPLE], ) -@freeze_time("2022-09-15 03:00:00-07:00") +@pytest.mark.freeze_time("2022-09-15 03:00:00-07:00") async def test_browse_media_time( hass: HomeAssistant, ufp: MockUFPFixture, @@ -537,7 +536,7 @@ TWO_MONTH_TIMEZONE = ( ("start", "months"), [ONE_MONTH_TIMEZONE, TWO_MONTH_TIMEZONE], ) -@freeze_time("2022-08-31 21:00:00-07:00") +@pytest.mark.freeze_time("2022-08-31 21:00:00-07:00") async def test_browse_media_time_timezone( hass: HomeAssistant, ufp: MockUFPFixture, @@ -713,7 +712,7 @@ async def test_browse_media_eventthumb( assert browse.media_class == MediaClass.IMAGE -@freeze_time("2022-09-15 03:00:00-07:00") +@pytest.mark.freeze_time("2022-09-15 03:00:00-07:00") async def test_browse_media_day( hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ) -> None: From 31c988c4f0dac38c90ae2dcfe7c40dfbafd8d40e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 15:03:26 -1000 Subject: [PATCH 0693/1058] Fix index not being dropped on postgresql databases with a schema prefix (#90144) * Fix index not being dropped on postgresql databases with a schema prefix Added logging in case index drops fail so we can tell why in the future * coverage --- .../components/recorder/migration.py | 108 ++++++++---------- tests/components/recorder/test_migrate.py | 47 +++++++- 2 files changed, 92 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 927097b18fd2..931422b64f40 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -297,6 +297,19 @@ def _create_index( _LOGGER.debug("Finished creating %s", index_name) +def _execute_or_collect_error( + session_maker: Callable[[], Session], query: str, errors: list[str] +) -> bool: + """Execute a query or collect an error.""" + with session_scope(session=session_maker()) as session: + try: + session.connection().execute(text(query)) + return True + except SQLAlchemyError as err: + errors.append(str(err)) + return False + + def _drop_index( session_maker: Callable[[], Session], table_name: str, @@ -322,74 +335,45 @@ def _drop_index( index_name, table_name, ) - success = False + index_to_drop: str | None = None + with session_scope(session=session_maker()) as session: + index_to_drop = get_index_by_name(session, table_name, index_name) - # Engines like DB2/Oracle - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute(text(f"DROP INDEX {index_name}")) - success = True - - # Engines like SQLite, SQL Server - if not success: - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute( - text( - "DROP INDEX {table}.{index}".format( - index=index_name, table=table_name - ) - ) - ) - success = True - - if not success: - # Engines like MySQL, MS Access - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - connection = session.connection() - connection.execute( - text( - "DROP INDEX {index} ON {table}".format( - index=index_name, table=table_name - ) - ) - ) - success = True - - if not success: - # Engines like postgresql may have a prefix - # ex idx_16532_ix_events_event_type_time_fired - with session_scope(session=session_maker()) as session, contextlib.suppress( - SQLAlchemyError - ): - if index_to_drop := get_index_by_name(session, table_name, index_name): - connection.execute(text(f"DROP INDEX {index_to_drop}")) - success = True - - if success: + if index_to_drop is None: _LOGGER.debug( - "Finished dropping index %s from table %s", index_name, table_name + "The index %s on table %s no longer exists", index_name, table_name ) return - if quiet: - return + errors: list[str] = [] + for query in ( + # Engines like DB2/Oracle + f"DROP INDEX {index_name}", + # Engines like SQLite, SQL Server + f"DROP INDEX {table_name}.{index_name}", + # Engines like MySQL, MS Access + f"DROP INDEX {index_name} ON {table_name}", + # Engines like postgresql may have a prefix + # ex idx_16532_ix_events_event_type_time_fired + f"DROP INDEX {index_to_drop}", + ): + if _execute_or_collect_error(session_maker, query, errors): + _LOGGER.debug( + "Finished dropping index %s from table %s", index_name, table_name + ) + return - _LOGGER.warning( - ( - "Failed to drop index `%s` from table `%s`. Schema " - "Migration will continue; this is not a " - "critical operation" - ), - index_name, - table_name, - ) + if not quiet: + _LOGGER.warning( + ( + "Failed to drop index `%s` from table `%s`. Schema " + "Migration will continue; this is not a " + "critical operation: %s" + ), + index_name, + table_name, + errors, + ) def _add_columns( diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index b23b7a2dfc98..fe4f1e016f5c 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -15,6 +15,7 @@ from sqlalchemy.exc import ( InternalError, OperationalError, ProgrammingError, + SQLAlchemyError, ) from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -492,7 +493,51 @@ def test_forgiving_add_index(recorder_db_url: str) -> None: with Session(engine) as session: instance = Mock() instance.get_session = Mock(return_value=session) - migration._create_index(instance.get_session, "states", "ix_states_context_id") + migration._create_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + engine.dispose() + + +def test_forgiving_drop_index( + recorder_db_url: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test that drop index will continue if index drop fails.""" + engine = create_engine(recorder_db_url, poolclass=StaticPool) + db_schema.Base.metadata.create_all(engine) + with Session(engine) as session: + instance = Mock() + instance.get_session = Mock(return_value=session) + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + + with patch( + "homeassistant.components.recorder.migration.get_index_by_name", + return_value="ix_states_context_id_bin", + ), patch.object( + session, "connection", side_effect=SQLAlchemyError("connection failure") + ): + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin" + ) + assert "Failed to drop index" in caplog.text + assert "connection failure" in caplog.text + caplog.clear() + with patch( + "homeassistant.components.recorder.migration.get_index_by_name", + return_value="ix_states_context_id_bin", + ), patch.object( + session, "connection", side_effect=SQLAlchemyError("connection failure") + ): + migration._drop_index( + instance.get_session, "states", "ix_states_context_id_bin", quiet=True + ) + assert "Failed to drop index" not in caplog.text + assert "connection failure" not in caplog.text engine.dispose() From 8a591fa16e9fba77dfbeba1f52299e32b5ac06a6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 15:17:36 -1000 Subject: [PATCH 0694/1058] Add auto repairs for events schema (#90136) * Add auto repairs for events schema * Add auto repairs for events schema * Add auto repairs for events schema * Add auto repairs for events schema * Add auto repairs for events schema * fix bug - wrong table --- .../recorder/auto_repairs/events/__init__.py | 1 + .../recorder/auto_repairs/events/schema.py | 31 ++++++++ .../components/recorder/migration.py | 6 ++ .../recorder/auto_repairs/events/__init__.py | 1 + .../auto_repairs/events/test_schema.py | 76 +++++++++++++++++++ .../recorder/auto_repairs/states/__init__.py | 4 - .../auto_repairs/statistics/__init__.py | 4 - 7 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/recorder/auto_repairs/events/__init__.py create mode 100644 homeassistant/components/recorder/auto_repairs/events/schema.py create mode 100644 tests/components/recorder/auto_repairs/events/__init__.py create mode 100644 tests/components/recorder/auto_repairs/events/test_schema.py diff --git a/homeassistant/components/recorder/auto_repairs/events/__init__.py b/homeassistant/components/recorder/auto_repairs/events/__init__.py new file mode 100644 index 000000000000..66ae1a1407ee --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/__init__.py @@ -0,0 +1 @@ +"""events repairs for Recorder.""" diff --git a/homeassistant/components/recorder/auto_repairs/events/schema.py b/homeassistant/components/recorder/auto_repairs/events/schema.py new file mode 100644 index 000000000000..e32cbd4df7f9 --- /dev/null +++ b/homeassistant/components/recorder/auto_repairs/events/schema.py @@ -0,0 +1,31 @@ +"""Events schema repairs.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...db_schema import EventData, Events +from ..schema import ( + correct_db_schema_precision, + correct_db_schema_utf8, + validate_db_schema_precision, + validate_table_schema_supports_utf8, +) + +if TYPE_CHECKING: + from ... import Recorder + + +def validate_db_schema(instance: Recorder) -> set[str]: + """Do some basic checks for common schema errors caused by manual migration.""" + return validate_table_schema_supports_utf8( + instance, EventData, (EventData.shared_data,) + ) | validate_db_schema_precision(instance, Events) + + +def correct_db_schema( + instance: Recorder, + schema_errors: set[str], +) -> None: + """Correct issues detected by validate_db_schema.""" + correct_db_schema_utf8(instance, EventData, schema_errors) + correct_db_schema_precision(instance, Events, schema_errors) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 931422b64f40..0eee065a0ca8 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -28,6 +28,10 @@ from homeassistant.core import HomeAssistant from homeassistant.util.enum import try_parse_enum from homeassistant.util.ulid import ulid_to_bytes +from .auto_repairs.events.schema import ( + correct_db_schema as events_correct_db_schema, + validate_db_schema as events_validate_db_schema, +) from .auto_repairs.states.schema import ( correct_db_schema as states_correct_db_schema, validate_db_schema as states_validate_db_schema, @@ -199,6 +203,7 @@ def _find_schema_errors( schema_errors: set[str] = set() schema_errors |= statistics_validate_db_schema(instance) schema_errors |= states_validate_db_schema(instance) + schema_errors |= events_validate_db_schema(instance) return schema_errors @@ -250,6 +255,7 @@ def migrate_schema( ) statistics_correct_db_schema(instance, schema_errors) states_correct_db_schema(instance, schema_errors) + events_correct_db_schema(instance, schema_errors) if current_version != SCHEMA_VERSION: instance.queue_task(PostSchemaMigrationTask(current_version, SCHEMA_VERSION)) diff --git a/tests/components/recorder/auto_repairs/events/__init__.py b/tests/components/recorder/auto_repairs/events/__init__.py new file mode 100644 index 000000000000..fca6a655ba4b --- /dev/null +++ b/tests/components/recorder/auto_repairs/events/__init__.py @@ -0,0 +1 @@ +"""Tests for Recorder component.""" diff --git a/tests/components/recorder/auto_repairs/events/test_schema.py b/tests/components/recorder/auto_repairs/events/test_schema.py new file mode 100644 index 000000000000..b19ff4ca5033 --- /dev/null +++ b/tests/components/recorder/auto_repairs/events/test_schema.py @@ -0,0 +1,76 @@ +"""The test repairing events schema.""" + +# pylint: disable=invalid-name +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.core import HomeAssistant + +from ...common import async_wait_recording_done + +from tests.typing import RecorderInstanceGenerator + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +@pytest.mark.parametrize("db_engine", ("mysql", "postgresql")) +async def test_validate_db_schema_fix_float_issue( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + db_engine, +) -> None: + """Test validating DB schema with postgresql and mysql. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", db_engine + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_db_schema_precision", + return_value={"events.double precision"}, + ), patch( + "homeassistant.components.recorder.migration._modify_columns" + ) as modify_columns_mock: + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: events.double precision" + in caplog.text + ) + modification = [ + "time_fired_ts DOUBLE PRECISION", + ] + modify_columns_mock.assert_called_once_with(ANY, ANY, "events", modification) + + +@pytest.mark.parametrize("enable_schema_validation", [True]) +async def test_validate_db_schema_fix_utf8_issue_event_data( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validating DB schema with MySQL. + + Note: The test uses SQLite, the purpose is only to exercise the code. + """ + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", "mysql" + ), patch( + "homeassistant.components.recorder.auto_repairs.schema._validate_table_schema_supports_utf8", + return_value={"event_data.4-byte UTF-8"}, + ): + await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + assert "Schema validation failed" not in caplog.text + assert ( + "Database is about to correct DB schema errors: event_data.4-byte UTF-8" + in caplog.text + ) + assert ( + "Updating character set and collation of table event_data to utf8mb4" + in caplog.text + ) diff --git a/tests/components/recorder/auto_repairs/states/__init__.py b/tests/components/recorder/auto_repairs/states/__init__.py index 6e98d881ea9c..fca6a655ba4b 100644 --- a/tests/components/recorder/auto_repairs/states/__init__.py +++ b/tests/components/recorder/auto_repairs/states/__init__.py @@ -1,5 +1 @@ """Tests for Recorder component.""" - -import pytest - -pytest.register_assert_rewrite("tests.components.recorder.common") diff --git a/tests/components/recorder/auto_repairs/statistics/__init__.py b/tests/components/recorder/auto_repairs/statistics/__init__.py index 6e98d881ea9c..fca6a655ba4b 100644 --- a/tests/components/recorder/auto_repairs/statistics/__init__.py +++ b/tests/components/recorder/auto_repairs/statistics/__init__.py @@ -1,5 +1 @@ """Tests for Recorder component.""" - -import pytest - -pytest.register_assert_rewrite("tests.components.recorder.common") From ca576d45acf44530c1fe932518132f9650ad12ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 16:03:41 -1000 Subject: [PATCH 0695/1058] Cache decode of JWT tokens (#90013) --- homeassistant/auth/__init__.py | 10 +- homeassistant/auth/jwt_wrapper.py | 116 +++++++++++++++++ homeassistant/components/http/auth.py | 3 +- tests/auth/test_init.py | 173 ++++++++++++++++++++++++++ tests/auth/test_jwt_wrapper.py | 12 ++ 5 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 homeassistant/auth/jwt_wrapper.py create mode 100644 tests/auth/test_jwt_wrapper.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 5c401570deea..9a537174270d 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -14,7 +14,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.util import dt as dt_util -from . import auth_store, models +from . import auth_store, jwt_wrapper, models from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config @@ -555,9 +555,7 @@ class AuthManager: ) -> models.RefreshToken | None: """Return refresh token if an access token is valid.""" try: - unverif_claims = jwt.decode( - token, algorithms=["HS256"], options={"verify_signature": False} - ) + unverif_claims = jwt_wrapper.unverified_hs256_token_decode(token) except jwt.InvalidTokenError: return None @@ -573,7 +571,9 @@ class AuthManager: issuer = refresh_token.id try: - jwt.decode(token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"]) + jwt_wrapper.verify_and_decode( + token, jwt_key, leeway=10, issuer=issuer, algorithms=["HS256"] + ) except jwt.InvalidTokenError: return None diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py new file mode 100644 index 000000000000..546e4afdcfa6 --- /dev/null +++ b/homeassistant/auth/jwt_wrapper.py @@ -0,0 +1,116 @@ +"""Provide a wrapper around JWT that caches decoding tokens. + +Since we decode the same tokens over and over again +we can cache the result of the decode of valid tokens +to speed up the process. +""" +from __future__ import annotations + +from datetime import timedelta +from functools import lru_cache, partial +from typing import Any + +from jwt import DecodeError, PyJWS, PyJWT + +from homeassistant.util.json import json_loads + +JWT_TOKEN_CACHE_SIZE = 16 +MAX_TOKEN_SIZE = 8192 + +_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss") + +_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | { + "require": [] +} +_NO_VERIFY_OPTIONS = {f"verify_{key}": False for key in _VERIFY_KEYS} + + +class _PyJWSWithLoadCache(PyJWS): + """PyJWS with a dedicated load implementation.""" + + @lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) + # We only ever have a global instance of this class + # so we do not have to worry about the LRU growing + # each time we create a new instance. + def _load(self, jwt: str | bytes) -> tuple[bytes, bytes, dict, bytes]: + """Load a JWS.""" + return super()._load(jwt) + + +_jws = _PyJWSWithLoadCache() + + +@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) +def _decode_payload(json_payload: str) -> dict[str, Any]: + """Decode the payload from a JWS dictionary.""" + try: + payload = json_loads(json_payload) + except ValueError as err: + raise DecodeError(f"Invalid payload string: {err}") from err + if not isinstance(payload, dict): + raise DecodeError("Invalid payload string: must be a json object") + return payload + + +class _PyJWTWithVerify(PyJWT): + """PyJWT with a fast decode implementation.""" + + def decode_payload( + self, jwt: str, key: str, options: dict[str, Any], algorithms: list[str] + ) -> dict[str, Any]: + """Decode a JWT's payload.""" + if len(jwt) > MAX_TOKEN_SIZE: + # Avoid caching impossible tokens + raise DecodeError("Token too large") + return _decode_payload( + _jws.decode_complete( + jwt=jwt, + key=key, + algorithms=algorithms, + options=options, + )["payload"] + ) + + def verify_and_decode( + self, + jwt: str, + key: str, + algorithms: list[str], + issuer: str | None = None, + leeway: int | float | timedelta = 0, + options: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Verify a JWT's signature and claims.""" + merged_options = {**_VERIFY_OPTIONS, **(options or {})} + payload = self.decode_payload( + jwt=jwt, + key=key, + options=merged_options, + algorithms=algorithms, + ) + # These should never be missing since we verify them + # but this is an additional safeguard to make sure + # nothing slips through. + assert "exp" in payload, "exp claim is required" + assert "iat" in payload, "iat claim is required" + self._validate_claims( # type: ignore[no-untyped-call] + payload=payload, + options=merged_options, + issuer=issuer, + leeway=leeway, + ) + return payload + + +_jwt = _PyJWTWithVerify() # type: ignore[no-untyped-call] +verify_and_decode = _jwt.verify_and_decode +unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)( + partial( + _jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS + ) +) + +__all__ = [ + "unverified_hs256_token_decode", + "verify_and_decode", +] diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 5213cd1b0722..ec8c7de9899a 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -13,6 +13,7 @@ from aiohttp.web import Application, Request, StreamResponse, middleware import jwt from yarl import URL +from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api @@ -175,7 +176,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: return False try: - claims = jwt.decode( + claims = jwt_wrapper.verify_and_decode( signature, secret, algorithms=["HS256"], options={"verify_iss": False} ) except jwt.InvalidTokenError: diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 3d3674e13730..83c08dd73ee8 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from typing import Any from unittest.mock import Mock, patch +from freezegun import freeze_time import jwt import pytest import voluptuous as vol @@ -1127,3 +1128,175 @@ async def test_event_user_updated_fires(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(events) == 1 + + +async def test_access_token_with_invalid_signature(mock_hass) -> None: + """Test rejecting access tokens with an invalid signature.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + 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) + assert rt.id == refresh_token.id + + # Now we corrupt the signature + header, payload, signature = access_token.split(".") + invalid_signature = "a" * len(signature) + invalid_token = f"{header}.{payload}.{invalid_signature}" + + assert access_token != invalid_token + + result = await manager.async_validate_access_token(invalid_token) + assert result is None + + +async def test_access_token_with_null_signature(mock_hass) -> None: + """Test rejecting access tokens with a null signature.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + 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) + assert rt.id == refresh_token.id + + # Now we make the signature all nulls + header, payload, signature = access_token.split(".") + invalid_signature = "\0" * len(signature) + invalid_token = f"{header}.{payload}.{invalid_signature}" + + assert access_token != invalid_token + + result = await manager.async_validate_access_token(invalid_token) + assert result is None + + +async def test_access_token_with_empty_signature(mock_hass) -> None: + """Test rejecting access tokens with an empty signature.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + 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) + assert rt.id == refresh_token.id + + # Now we make the signature all nulls + header, payload, _ = access_token.split(".") + invalid_token = f"{header}.{payload}." + + assert access_token != invalid_token + + result = await manager.async_validate_access_token(invalid_token) + assert result is None + + +async def test_access_token_with_empty_key(mock_hass) -> None: + """Test rejecting access tokens with an empty key.""" + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Good Client", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=3000), + ) + assert refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + + access_token = manager.async_create_access_token(refresh_token) + + await 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 + + +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 + + +async def test_reject_token_with_invalid_json_payload(mock_hass) -> None: + """Test rejecting access tokens with invalid json payload.""" + jws = jwt.PyJWS() + token_with_invalid_json = jws.encode( + 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 + + +async def test_reject_token_with_not_dict_json_payload(mock_hass) -> None: + """Test rejecting access tokens with not a dict json payload.""" + jws = jwt.PyJWS() + token_not_a_dict_json = jws.encode( + 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 + + +async def test_access_token_that_expires_soon(mock_hass) -> None: + """Test access token from refresh token that expires very soon.""" + now = dt_util.utcnow() + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Token that expires very soon", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(seconds=1), + ) + 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) + assert rt.id == refresh_token.id + + with freeze_time(now + timedelta(minutes=1)): + assert await manager.async_validate_access_token(access_token) is None + + +async def test_access_token_from_the_future(mock_hass) -> None: + """Test we reject an access token from the future.""" + now = dt_util.utcnow() + manager = await auth.auth_manager_from_config(mock_hass, [], []) + user = MockUser().add_to_auth_manager(manager) + with freeze_time(now + timedelta(days=365)): + refresh_token = await manager.async_create_refresh_token( + user, + client_name="Token that expires very soon", + token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN, + access_token_expiration=timedelta(days=10), + ) + assert ( + refresh_token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN + ) + access_token = manager.async_create_access_token(refresh_token) + + assert await 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) + assert rt.id == refresh_token.id diff --git a/tests/auth/test_jwt_wrapper.py b/tests/auth/test_jwt_wrapper.py new file mode 100644 index 000000000000..297d4dd5d7fc --- /dev/null +++ b/tests/auth/test_jwt_wrapper.py @@ -0,0 +1,12 @@ +"""Tests for the Home Assistant auth jwt_wrapper module.""" + +import jwt +import pytest + +from homeassistant.auth import jwt_wrapper + + +async def test_reject_access_token_with_impossible_large_size() -> None: + """Test rejecting access tokens with impossible sizes.""" + with pytest.raises(jwt.DecodeError): + jwt_wrapper.unverified_hs256_token_decode("a" * 10000) From dcc52bd366b9a1f136f05ef22cd840443ff50335 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 16:10:47 -1000 Subject: [PATCH 0696/1058] Bump PySwitchbot to 0.37.4 (#90146) fixes #90090 fixes #89061 changelog: https://github.com/Danielhiversen/pySwitchbot/compare/0.37.3...0.37.4 --- 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 2637f578b8ff..ada24bcee577 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.3"] + "requirements": ["PySwitchbot==0.37.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 42355c107367..23ae790f3609 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9c4ae887606..dfd65179546d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.3 +PySwitchbot==0.37.4 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 12352b2ce1ca27eb3ecbb54afa2d64e0c10d8d44 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Mar 2023 22:54:09 -0400 Subject: [PATCH 0697/1058] Always enforce URL param ordering for signed URLs (#90148) Always enforce URL param ordering --- homeassistant/components/http/auth.py | 15 +++++++------- tests/components/http/test_auth.py | 28 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index ec8c7de9899a..f2cfe0674047 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -61,9 +61,7 @@ def async_sign_path( url = URL(path) now = dt_util.utcnow() - params = dict(sorted(url.query.items())) - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS] encoded = jwt.encode( { "iss": refresh_token_id, @@ -76,7 +74,7 @@ def async_sign_path( algorithm="HS256", ) - params[SIGN_QUERY_PARAM] = encoded + params.append((SIGN_QUERY_PARAM, encoded)) url = url.with_query(params) return f"{url.path}?{url.query_string}" @@ -185,10 +183,11 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False - params = dict(sorted(request.query.items())) - del params[SIGN_QUERY_PARAM] - for param in SAFE_QUERY_PARAMS: - params.pop(param, None) + params = [ + list(itm) # claims stores tuples as lists + for itm in request.query.items() + if itm[0] not in SAFE_QUERY_PARAMS and itm[0] != SIGN_QUERY_PARAM + ] if claims["params"] != params: return False diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index fb00640cdc5f..246572e64f85 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -352,6 +352,12 @@ async def test_auth_access_signed_path_with_query_param( data = await req.json() assert data["user_id"] == refresh_token.user.id + # Without query params not allowed + url = yarl.URL(signed_path) + signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}" + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + async def test_auth_access_signed_path_with_query_param_order( hass: HomeAssistant, @@ -374,12 +380,24 @@ async def test_auth_access_signed_path_with_query_param_order( refresh_token_id=refresh_token.id, ) url = yarl.URL(signed_path) - signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - data = await req.json() - assert data["user_id"] == refresh_token.user.id + # Change order + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Duplicate a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test&foo=aaa&foo=bar" + ) + assert req.status == HTTPStatus.UNAUTHORIZED + + # Remove a param + req = await client.get( + f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&test=test" + ) + assert req.status == HTTPStatus.UNAUTHORIZED async def test_auth_access_signed_path_with_query_param_safe_param( From 98787383210ffb3ba655d2f957349ae4721e0393 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 18:14:54 -1000 Subject: [PATCH 0698/1058] Use rel_url for looking up frontend panels (#90149) * Use rel_url for looking up frontend panels request.url builds a new URL every time where-as rel_url is always available https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.BaseRequest.rel_url * Use rel_url for looking up frontend panels request.url builds a new URL every time where-as rel_url is always available https://docs.aiohttp.org/en/stable/web_reference.html#aiohttp.web.BaseRequest.rel_url --- homeassistant/components/frontend/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index b152b2d65d8f..8c04e5919683 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -530,8 +530,9 @@ class IndexView(web_urldispatcher.AbstractResource): """ if ( request.path != "/" - and len(request.url.parts) > 1 - and request.url.parts[1] not in self.hass.data[DATA_PANELS] + and (parts := request.rel_url.parts) + and len(parts) > 1 + and parts[1] not in self.hass.data[DATA_PANELS] ): return None, set() From 1e64a55a1a8d14ffc332e72d37158dc1b8239a04 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 08:08:52 +0100 Subject: [PATCH 0699/1058] Add missing translation for invalid imap folder (#90154) Add missing translation for invalid folder --- homeassistant/components/imap/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 2fedef55f61d..bb03f82bb76d 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -24,6 +24,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "The specified charset is not supported", + "invalid_folder": "The selected folder is invalid", "invalid_search": "The selected search is invalid" }, "abort": { From b1519236195ea54c5aece60bb65935d4f61b4106 Mon Sep 17 00:00:00 2001 From: solazs Date: Thu, 23 Mar 2023 08:56:47 +0100 Subject: [PATCH 0700/1058] Add health mode to gree integration (#89764) Add health mode to gree integration. --- homeassistant/components/gree/switch.py | 37 +++++++++++++++++++++++++ tests/components/gree/test_switch.py | 35 +++++++++++++++++++---- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 62189fdde063..ffef6b08a94c 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -26,6 +26,7 @@ async def async_setup_entry( async_add_entities( [ GreePanelLightSwitchEntity(coordinator), + GreeHealthModeSwitchEntity(coordinator), GreeQuietModeSwitchEntity(coordinator), GreeFreshAirSwitchEntity(coordinator), GreeXFanSwitchEntity(coordinator), @@ -75,6 +76,42 @@ class GreePanelLightSwitchEntity(GreeEntity, SwitchEntity): self.async_write_ha_state() +class GreeHealthModeSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the health mode on the device.""" + + def __init__(self, coordinator): + """Initialize the Gree device.""" + super().__init__(coordinator, "Health mode") + self._attr_entity_registry_enabled_default = False + + @property + def icon(self) -> str | None: + """Return the icon for the device.""" + return "mdi:pine-tree" + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SwitchDeviceClass.SWITCH + + @property + def is_on(self) -> bool: + """Return if the health mode is turned on.""" + return self.coordinator.device.anion + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + self.coordinator.device.anion = True + await self.coordinator.push_state_update() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + self.coordinator.device.anion = False + await self.coordinator.push_state_update() + self.async_write_ha_state() + + class GreeQuietModeSwitchEntity(GreeEntity, SwitchEntity): """Representation of the quiet mode state of the device.""" diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 75af20d37eb9..85b9a41caff2 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -14,11 +14,13 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry ENTITY_ID_LIGHT_PANEL = f"{DOMAIN}.fake_device_1_panel_light" +ENTITY_ID_HEALTH_MODE = f"{DOMAIN}.fake_device_1_health_mode" ENTITY_ID_QUIET = f"{DOMAIN}.fake_device_1_quiet" ENTITY_ID_FRESH_AIR = f"{DOMAIN}.fake_device_1_fresh_air" ENTITY_ID_XFAN = f"{DOMAIN}.fake_device_1_xfan" @@ -31,16 +33,29 @@ async def async_setup_gree(hass): await hass.async_block_till_done() +async def test_health_mode_disabled_by_default(hass): + """Test for making sure health mode is disabled on first load.""" + await async_setup_gree(hass) + + assert ( + er.async_get(hass).async_get(ENTITY_ID_HEALTH_MODE).disabled_by + == er.RegistryEntryDisabler.INTEGRATION + ) + + @pytest.mark.parametrize( "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) -async def test_send_switch_on(hass: HomeAssistant, entity) -> None: +async def test_send_switch_on( + hass: HomeAssistant, entity, entity_registry_enabled_by_default +) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -60,13 +75,14 @@ async def test_send_switch_on(hass: HomeAssistant, entity) -> None: "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) async def test_send_switch_on_device_timeout( - hass: HomeAssistant, device, entity + hass: HomeAssistant, device, entity, entity_registry_enabled_by_default ) -> None: """Test for sending power on command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -89,12 +105,15 @@ async def test_send_switch_on_device_timeout( "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) -async def test_send_switch_off(hass: HomeAssistant, entity) -> None: +async def test_send_switch_off( + hass: HomeAssistant, entity, entity_registry_enabled_by_default +) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -114,12 +133,15 @@ async def test_send_switch_off(hass: HomeAssistant, entity) -> None: "entity", [ ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_HEALTH_MODE, ENTITY_ID_QUIET, ENTITY_ID_FRESH_AIR, ENTITY_ID_XFAN, ], ) -async def test_send_switch_toggle(hass: HomeAssistant, entity) -> None: +async def test_send_switch_toggle( + hass: HomeAssistant, entity, entity_registry_enabled_by_default +) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -164,12 +186,15 @@ async def test_send_switch_toggle(hass: HomeAssistant, entity) -> None: ("entity", "name"), [ (ENTITY_ID_LIGHT_PANEL, "Panel Light"), + (ENTITY_ID_HEALTH_MODE, "Health mode"), (ENTITY_ID_QUIET, "Quiet"), (ENTITY_ID_FRESH_AIR, "Fresh Air"), (ENTITY_ID_XFAN, "XFan"), ], ) -async def test_entity_name(hass: HomeAssistant, entity, name) -> None: +async def test_entity_name( + hass: HomeAssistant, entity, name, entity_registry_enabled_by_default +) -> None: """Test for name property.""" await async_setup_gree(hass) state = hass.states.get(entity) From 6739542a5d72496b27740492a6f8b3c21914ceee Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Mar 2023 09:18:35 +0100 Subject: [PATCH 0701/1058] Simplify some multi pan code (#90135) * Simplify some multi pan code * Adjust ZHA config flow --- homeassistant/components/hassio/__init__.py | 5 ++++ .../silabs_multiprotocol_addon.py | 24 +++++++++---------- .../homeassistant_sky_connect/__init__.py | 7 +++--- .../homeassistant_yellow/__init__.py | 8 +++---- homeassistant/components/zha/config_flow.py | 2 +- tests/components/hassio/test_init.py | 10 ++++++++ 6 files changed, 34 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 25482ddde95a..d5449cf927bd 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -244,6 +244,11 @@ HARDWARE_INTEGRATIONS = { } +def hostname_from_addon_slug(addon_slug: str) -> str: + """Return hostname of add-on.""" + return addon_slug.replace("_", "-") + + @callback @bind_hass def get_info(hass: HomeAssistant) -> dict[str, Any] | None: diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index bba6b447c7e2..41f16462cd8c 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -15,6 +15,7 @@ from homeassistant.components.hassio import ( AddonInfo, AddonManager, AddonState, + hostname_from_addon_slug, is_hassio, ) from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN @@ -64,12 +65,13 @@ class SerialPortSettings: flow_control: bool -def get_zigbee_socket(hass: HomeAssistant, addon_info: AddonInfo) -> str: +def get_zigbee_socket() -> str: """Return the zigbee socket. Raises AddonError on error """ - return f"socket://{addon_info.hostname}:9999" + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return f"socket://{hostname}:9999" class BaseMultiPanFlow(FlowHandler, ABC): @@ -290,7 +292,7 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): "new_discovery_info": { "name": self._zha_name(), "port": { - "path": get_zigbee_socket(self.hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", }, @@ -386,24 +388,22 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None: raise HomeAssistantError -async def get_multi_pan_addon_info( - hass: HomeAssistant, device_path: str -) -> AddonInfo | None: - """Return AddonInfo if the multi-PAN addon is using the given device. +async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> bool: + """Return True if the multi-PAN addon is using the given device. - Returns None if Hass.io is not loaded, the addon is not running or the addon is + Returns False if Hass.io is not loaded, the addon is not running or the addon is connected to another device. """ if not is_hassio(hass): - return None + return False addon_manager: AddonManager = get_addon_manager(hass) addon_info: AddonInfo = await addon_manager.async_get_addon_info() if addon_info.state != AddonState.RUNNING: - return None + return False if addon_info.options["device"] != device_path: - return None + return False - return addon_info + return True diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 54c11fd37928..0f7ec7047155 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -4,8 +4,8 @@ from __future__ import annotations from homeassistant.components import usb from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, - get_multi_pan_addon_info, get_zigbee_socket, + multi_pan_addon_using_device, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -33,9 +33,8 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: usb_dev = entry.data["device"] dev_path = await hass.async_add_executor_job(usb.get_serial_by_id, usb_dev) - addon_info = await get_multi_pan_addon_info(hass, dev_path) - if not addon_info: + if not await multi_pan_addon_using_device(hass, dev_path): usb_info = get_usb_service_info(entry) await hass.config_entries.flow.async_init( "zha", @@ -47,7 +46,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: hw_discovery_data = { "name": "SkyConnect Multi-PAN", "port": { - "path": get_zigbee_socket(hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", } diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 72df6a5707bb..30015d1bae44 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -4,8 +4,8 @@ from __future__ import annotations from homeassistant.components.hassio import get_os_info from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, - get_multi_pan_addon_info, get_zigbee_socket, + multi_pan_addon_using_device, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -31,15 +31,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except HomeAssistantError as err: raise ConfigEntryNotReady from err - addon_info = await get_multi_pan_addon_info(hass, RADIO_DEVICE) - - if not addon_info: + if not await multi_pan_addon_using_device(hass, RADIO_DEVICE): hw_discovery_data = ZHA_HW_DISCOVERY_DATA else: hw_discovery_data = { "name": "Yellow Multi-PAN", "port": { - "path": get_zigbee_socket(hass, addon_info), + "path": get_zigbee_socket(), }, "radio_type": "ezsp", } diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 05dc67314ed7..53c4e3388108 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -101,7 +101,7 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: addon_port = ListPortInfo( - device=silabs_multiprotocol_addon.get_zigbee_socket(hass, addon_info), + device=silabs_multiprotocol_addon.get_zigbee_socket(), skip_link_detection=True, ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index ead65d812927..1d86699d0952 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.hassio import ( DOMAIN, STORAGE_KEY, async_get_addon_store_info, + hostname_from_addon_slug, ) from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -871,3 +872,12 @@ async def test_get_store_addon_info( data = await async_get_addon_store_info(hass, "test") assert data["name"] == "bla" assert aioclient_mock.call_count == 1 + + +def test_hostname_from_addon_slug() -> None: + """Test hostname_from_addon_slug.""" + assert hostname_from_addon_slug("mqtt") == "mqtt" + assert ( + hostname_from_addon_slug("core_silabs_multiprotocol") + == "core-silabs-multiprotocol" + ) From 1a2fa51ac99bcf3abb5eba7fa2a170cef7280e05 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:01:01 +0100 Subject: [PATCH 0702/1058] Bump bimmer_connected to 0.13.0 (#90127) --- .../bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/__init__.py | 51 +++++- .../diagnostics/diagnostics_config_entry.json | 154 +++++++++++++++++- .../diagnostics/diagnostics_device.json | 154 +++++++++++++++++- ...x-crccs_v2_vehicles_WBY00000000REXI01.json | 60 +++++++ .../bmw-eadrax-vcs_v4_vehicles.json} | 0 ..._v4_vehicles_state_WBY00000000REXI01.json} | 0 9 files changed, 407 insertions(+), 18 deletions(-) create mode 100644 tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json rename tests/components/bmw_connected_drive/fixtures/vehicles/{I01/vehicles_v2_bmw_0.json => I01_REX/bmw-eadrax-vcs_v4_vehicles.json} (100%) rename tests/components/bmw_connected_drive/fixtures/vehicles/{I01/state_WBY00000000REXI01_0.json => I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json} (100%) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index cafaced5223e..f1768d5a0c7a 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer_connected==0.12.1"] + "requirements": ["bimmer_connected==0.13.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 23ae790f3609..a3ddeac5dbbc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ beautifulsoup4==4.11.1 bellows==0.34.10 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.12.1 +bimmer_connected==0.13.0 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dfd65179546d..5237078f5ee5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -358,7 +358,7 @@ beautifulsoup4==4.11.1 bellows==0.34.10 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.12.1 +bimmer_connected==0.13.0 # homeassistant.components.bluetooth bleak-retry-connector==3.0.1 diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index ed31b4308b81..12957db5cac2 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -1,10 +1,13 @@ """Tests for the for the BMW Connected Drive integration.""" -import json from pathlib import Path from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.const import VEHICLE_STATE_URL, VEHICLES_URL +from bimmer_connected.const import ( + VEHICLE_CHARGING_DETAILS_URL, + VEHICLE_STATE_URL, + VEHICLES_URL, +) import httpx import respx @@ -17,7 +20,12 @@ from homeassistant.components.bmw_connected_drive.const import ( from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, get_fixture_path, load_fixture +from tests.common import ( + MockConfigEntry, + get_fixture_path, + load_json_array_fixture, + load_json_object_fixture, +) FIXTURE_USER_INPUT = { CONF_USERNAME: "user@domain.com", @@ -42,6 +50,17 @@ FIXTURE_CONFIG_ENTRY = { } FIXTURE_PATH = Path(get_fixture_path("", integration=BMW_DOMAIN)) +FIXTURE_FILES = { + "vehicles": sorted(FIXTURE_PATH.rglob("*-eadrax-vcs_v4_vehicles.json")), + "states": { + p.stem.split("_")[-1]: p + for p in FIXTURE_PATH.rglob("*-eadrax-vcs_v4_vehicles_state_*.json") + }, + "charging": { + p.stem.split("_")[-1]: p + for p in FIXTURE_PATH.rglob("*-eadrax-crccs_v2_vehicles_*.json") + }, +} def vehicles_sideeffect(request: httpx.Request) -> httpx.Response: @@ -49,17 +68,31 @@ def vehicles_sideeffect(request: httpx.Request) -> httpx.Response: x_user_agent = request.headers.get("x-user-agent", "").split(";") brand = x_user_agent[1] vehicles = [] - for vehicle_file in FIXTURE_PATH.rglob(f"vehicles_v2_{brand}_*.json"): - vehicles.extend(json.loads(load_fixture(vehicle_file, integration=BMW_DOMAIN))) + for vehicle_file in FIXTURE_FILES["vehicles"]: + if vehicle_file.name.startswith(brand): + vehicles.extend( + load_json_array_fixture(vehicle_file, integration=BMW_DOMAIN) + ) return httpx.Response(200, json=vehicles) def vehicle_state_sideeffect(request: httpx.Request) -> httpx.Response: """Return /vehicles/state response.""" - state_file = next(FIXTURE_PATH.rglob(f"state_{request.headers['bmw-vin']}_*.json")) try: + state_file = FIXTURE_FILES["states"][request.headers["bmw-vin"]] return httpx.Response( - 200, json=json.loads(load_fixture(state_file, integration=BMW_DOMAIN)) + 200, json=load_json_object_fixture(state_file, integration=BMW_DOMAIN) + ) + except KeyError: + return httpx.Response(404) + + +def vehicle_charging_sideeffect(request: httpx.Request) -> httpx.Response: + """Return /vehicles/state response.""" + try: + charging_file = FIXTURE_FILES["charging"][request.headers["bmw-vin"]] + return httpx.Response( + 200, json=load_json_object_fixture(charging_file, integration=BMW_DOMAIN) ) except KeyError: return httpx.Response(404) @@ -75,6 +108,10 @@ def mock_vehicles() -> respx.Router: # Get vehicle state router.get(VEHICLE_STATE_URL).mock(side_effect=vehicle_state_sideeffect) + # Get vehicle charging details + router.get(VEHICLE_CHARGING_DETAILS_URL).mock( + side_effect=vehicle_charging_sideeffect + ) return router diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json index 9c56e0595b6e..12e85bb85234 100644 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json +++ b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json @@ -45,6 +45,64 @@ "mappingStatus": "CONFIRMED" }, "vin": "**REDACTED**", + "charging_settings": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + }, "is_metric": true, "fetched_at": "2022-07-10T11:00:00+00:00", "capabilities": { @@ -230,6 +288,7 @@ "charging_start_time_no_tz": "2022-07-10T18:01:00", "charging_end_time": null, "is_charger_connected": true, + "charging_target": 100, "account_timezone": { "_std_offset": "0:00:00", "_dst_offset": "0:00:00", @@ -376,7 +435,10 @@ "start_time": "18:01:00" }, "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING" + "charging_mode": "DELAYED_CHARGING", + "ac_current_limit": null, + "ac_available_limits": null, + "charging_preferences_service_pack": "TCB1" }, "available_attributes": [ "gps_position", @@ -384,6 +446,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -391,6 +454,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent", @@ -407,6 +475,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -414,6 +483,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent" @@ -422,6 +496,17 @@ "has_electric_drivetrain": true, "is_charging_plan_supported": true, "is_lsc_enabled": true, + "is_remote_charge_start_enabled": false, + "is_remote_charge_stop_enabled": false, + "is_remote_climate_start_enabled": true, + "is_remote_climate_stop_enabled": false, + "is_remote_horn_enabled": true, + "is_remote_lights_enabled": true, + "is_remote_lock_enabled": true, + "is_remote_sendpoi_enabled": true, + "is_remote_set_ac_limit_enabled": false, + "is_remote_set_target_soc_enabled": false, + "is_remote_unlock_enabled": true, "is_vehicle_active": false, "is_vehicle_tracking_enabled": false, "lsc_type": "ACTIVATED", @@ -433,7 +518,7 @@ ], "fingerprint": [ { - "filename": "bmw-vehicles.json", + "filename": "bmw-eadrax-vcs_v4_vehicles.json", "content": [ { "appVehicleType": "CONNECTED", @@ -476,9 +561,9 @@ } ] }, - { "filename": "mini-vehicles.json", "content": [] }, + { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, { - "filename": "bmw-vehicles_state_WBY0FINGERPRINT01.json", + "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", "content": { "capabilities": { "climateFunction": "AIR_CONDITIONING", @@ -652,6 +737,67 @@ } } } + }, + { + "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", + "content": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + } } ] } diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json index d76f2c807121..8e1fe5019c72 100644 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json +++ b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json @@ -44,6 +44,64 @@ "mappingStatus": "CONFIRMED" }, "vin": "**REDACTED**", + "charging_settings": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + }, "is_metric": true, "fetched_at": "2022-07-10T11:00:00+00:00", "capabilities": { @@ -229,6 +287,7 @@ "charging_start_time_no_tz": "2022-07-10T18:01:00", "charging_end_time": null, "is_charger_connected": true, + "charging_target": 100, "account_timezone": { "_std_offset": "0:00:00", "_dst_offset": "0:00:00", @@ -375,7 +434,10 @@ "start_time": "18:01:00" }, "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING" + "charging_mode": "DELAYED_CHARGING", + "ac_current_limit": null, + "ac_available_limits": null, + "charging_preferences_service_pack": "TCB1" }, "available_attributes": [ "gps_position", @@ -383,6 +445,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -390,6 +453,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent", @@ -406,6 +474,7 @@ "remaining_range_total", "mileage", "charging_time_remaining", + "charging_start_time", "charging_end_time", "charging_time_label", "charging_status", @@ -413,6 +482,11 @@ "remaining_battery_percent", "remaining_range_electric", "last_charging_end_result", + "ac_current_limit", + "charging_target", + "charging_mode", + "charging_preferences", + "is_pre_entry_climatization_enabled", "remaining_fuel", "remaining_range_fuel", "remaining_fuel_percent" @@ -421,6 +495,17 @@ "has_electric_drivetrain": true, "is_charging_plan_supported": true, "is_lsc_enabled": true, + "is_remote_charge_start_enabled": false, + "is_remote_charge_stop_enabled": false, + "is_remote_climate_start_enabled": true, + "is_remote_climate_stop_enabled": false, + "is_remote_horn_enabled": true, + "is_remote_lights_enabled": true, + "is_remote_lock_enabled": true, + "is_remote_sendpoi_enabled": true, + "is_remote_set_ac_limit_enabled": false, + "is_remote_set_target_soc_enabled": false, + "is_remote_unlock_enabled": true, "is_vehicle_active": false, "is_vehicle_tracking_enabled": false, "lsc_type": "ACTIVATED", @@ -431,7 +516,7 @@ }, "fingerprint": [ { - "filename": "bmw-vehicles.json", + "filename": "bmw-eadrax-vcs_v4_vehicles.json", "content": [ { "appVehicleType": "CONNECTED", @@ -474,9 +559,9 @@ } ] }, - { "filename": "mini-vehicles.json", "content": [] }, + { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, { - "filename": "bmw-vehicles_state_WBY0FINGERPRINT01.json", + "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", "content": { "capabilities": { "climateFunction": "AIR_CONDITIONING", @@ -650,6 +735,67 @@ } } } + }, + { + "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", + "content": { + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { "showDepartureTimers": false } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" + } } ] } diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json new file mode 100644 index 000000000000..03bfc1cae049 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-crccs_v2_vehicles_WBY00000000REXI01.json @@ -0,0 +1,60 @@ +{ + "chargeAndClimateSettings": { + "chargeAndClimateTimer": { + "showDepartureTimers": false + } + }, + "chargeAndClimateTimerDetail": { + "chargingMode": { + "chargingPreference": "CHARGING_WINDOW", + "endTimeSlot": "0001-01-01T01:30:00", + "startTimeSlot": "0001-01-01T18:01:00", + "type": "TIME_SLOT" + }, + "departureTimer": { + "type": "WEEKLY_DEPARTURE_TIMER", + "weeklyTimers": [ + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY" + ], + "id": 1, + "time": "0001-01-01T07:35:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "id": 2, + "time": "0001-01-01T18:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 3, + "time": "0001-01-01T07:00:00", + "timerAction": "DEACTIVATE" + }, + { + "daysOfTheWeek": [], + "id": 4, + "time": "0001-01-01T00:00:00", + "timerAction": "DEACTIVATE" + } + ] + }, + "isPreconditionForDepartureActive": false + }, + "servicePack": "TCB1" +} diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles.json similarity index 100% rename from tests/components/bmw_connected_drive/fixtures/vehicles/I01/vehicles_v2_bmw_0.json rename to tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles.json diff --git a/tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json b/tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json similarity index 100% rename from tests/components/bmw_connected_drive/fixtures/vehicles/I01/state_WBY00000000REXI01_0.json rename to tests/components/bmw_connected_drive/fixtures/vehicles/I01_REX/bmw-eadrax-vcs_v4_vehicles_state_WBY00000000REXI01.json From 10cf92246fb3144cd20d627b50c6c8a3abe8f42e Mon Sep 17 00:00:00 2001 From: On Freund Date: Thu, 23 Mar 2023 11:05:08 +0200 Subject: [PATCH 0703/1058] Bump pyrympro to 0.0.7 (#90118) --- homeassistant/components/rympro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json index 613a1c33613b..e14ac9af71f1 100644 --- a/homeassistant/components/rympro/manifest.json +++ b/homeassistant/components/rympro/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rympro", "iot_class": "cloud_polling", - "requirements": ["pyrympro==0.0.4"] + "requirements": ["pyrympro==0.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3ddeac5dbbc..0b472aafbd99 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1923,7 +1923,7 @@ pyroute2==0.7.5 pyruckus==0.16 # homeassistant.components.rympro -pyrympro==0.0.4 +pyrympro==0.0.7 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5237078f5ee5..1dcc06fa4179 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1397,7 +1397,7 @@ pyroute2==0.7.5 pyruckus==0.16 # homeassistant.components.rympro -pyrympro==0.0.4 +pyrympro==0.0.7 # homeassistant.components.sabnzbd pysabnzbd==1.1.1 From 2cb4ec82df5eec8ab1d9b7f8b87ac5cbd33a43a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 23:08:06 -1000 Subject: [PATCH 0704/1058] Bump yalexs-ble to 2.1.2 (#90156) --- 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 213f0237e124..7bbc6f042ef5 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.2.7", "yalexs-ble==2.1.1"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.2"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 6bb58752a00f..bb95a7038606 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.1.1"] + "requirements": ["yalexs-ble==2.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b472aafbd99..34e470760742 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2671,7 +2671,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.1 +yalexs-ble==2.1.2 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1dcc06fa4179..fbc282040f3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.1 +yalexs-ble==2.1.2 # homeassistant.components.august yalexs==1.2.7 From 4c26741e40316a966caf4bdff783cea5f3277ef8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:09:03 +0100 Subject: [PATCH 0705/1058] Bump actions/stale from 7.0.0 to 8.0.0 (#90155) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d8aaa998accd..5fb977f74d1e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 90 days stale PRs policy - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -53,7 +53,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +83,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v7.0.0 + uses: actions/stale@v8.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" From 50ea0c5cf2beba8fa535ae4091ad53985a1036fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Mar 2023 10:12:42 +0100 Subject: [PATCH 0706/1058] Tweak multiprotocol tests (#90163) --- .../test_silabs_multiprotocol_addon.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 57e4a23ab5f7..424e4126e05f 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -25,7 +25,7 @@ from tests.common import ( TEST_DOMAIN = "test" -class TestConfigFlow(ConfigFlow): +class FakeConfigFlow(ConfigFlow): """Handle a config flow for the silabs multiprotocol add-on.""" VERSION = 1 @@ -34,9 +34,9 @@ class TestConfigFlow(ConfigFlow): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> TestOptionsFlow: + ) -> FakeOptionsFlow: """Return the options flow.""" - return TestOptionsFlow(config_entry) + return FakeOptionsFlow(config_entry) async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" @@ -46,7 +46,7 @@ class TestConfigFlow(ConfigFlow): return self.async_create_entry(title="Test HW", data={}) -class TestOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): +class FakeOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): """Handle an option flow for the silabs multiprotocol add-on.""" async def _async_serial_port_settings( @@ -89,10 +89,10 @@ class TestOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): @pytest.fixture(autouse=True) def config_flow_handler( hass: HomeAssistant, current_request_with_host: Any -) -> Generator[TestConfigFlow, None, None]: +) -> Generator[FakeConfigFlow, None, None]: """Fixture for a test config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") - with mock_config_flow(TEST_DOMAIN, TestConfigFlow): + with mock_config_flow(TEST_DOMAIN, FakeConfigFlow): yield From 60ae1f99e023fd4d2280e4a98aa42c46f29a8b8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Mar 2023 23:13:18 -1000 Subject: [PATCH 0707/1058] Update powerwall strings for newer models (#90151) --- homeassistant/components/powerwall/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 213b7cc03dba..db8b212cc5e4 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -3,15 +3,15 @@ "flow_title": "{name} ({ip_address})", "step": { "user": { - "title": "Connect to the powerwall", - "description": "The password is usually the last 5 characters of the serial number for Backup Gateway and can be found in the Tesla app or the last 5 characters of the password found inside the door for Backup Gateway 2.", + "title": "Connect to the Powerwall", + "description": "The default password is printed inside the Backup Gateway for newer models. For older models, the default password is the last five characters of the serial number for Backup Gateway and can be found in the Tesla app.", "data": { "ip_address": "[%key:common::config_flow::data::ip%]", "password": "[%key:common::config_flow::data::password%]" } }, "reauth_confim": { - "title": "Reauthenticate the powerwall", + "title": "Reauthenticate the Powerwall", "description": "[%key:component::powerwall::config::step::user::description%]", "data": { "password": "[%key:common::config_flow::data::password%]" @@ -24,7 +24,7 @@ }, "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.", + "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%]" }, From 92bcb04e4fea58c74baa83708aeb93fe9a3db40f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:13:53 +0100 Subject: [PATCH 0708/1058] Adjust scaffold docstring (#90157) Co-authored-by: Martin Hjelmare --- script/scaffold/templates/config_flow/tests/conftest.py | 2 +- script/scaffold/templates/config_flow_helper/tests/conftest.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/scaffold/templates/config_flow/tests/conftest.py b/script/scaffold/templates/config_flow/tests/conftest.py index dab3d971a3ba..05993acc3296 100644 --- a/script/scaffold/templates/config_flow/tests/conftest.py +++ b/script/scaffold/templates/config_flow/tests/conftest.py @@ -1,4 +1,4 @@ -"""Test the NEW_NAME config flow.""" +"""Common fixtures for the NEW_NAME tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch diff --git a/script/scaffold/templates/config_flow_helper/tests/conftest.py b/script/scaffold/templates/config_flow_helper/tests/conftest.py index dab3d971a3ba..05993acc3296 100644 --- a/script/scaffold/templates/config_flow_helper/tests/conftest.py +++ b/script/scaffold/templates/config_flow_helper/tests/conftest.py @@ -1,4 +1,4 @@ -"""Test the NEW_NAME config flow.""" +"""Common fixtures for the NEW_NAME tests.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch From 568a731e2d28ca062af78c9038bbdd2e2891f8bf Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 23 Mar 2023 10:30:31 +0100 Subject: [PATCH 0709/1058] Use snapshot test for Landis+Gyr (#90126) Initial commit for snapshot test --- .../snapshots/test_sensor.ambr | 303 ++++++++++++++++++ .../landisgyr_heat_meter/test_sensor.py | 54 +--- 2 files changed, 310 insertions(+), 47 deletions(-) create mode 100644 tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..e149073d9c88 --- /dev/null +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_create_sensors + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage', + 'last_changed': , + 'last_updated': , + 'state': '456.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Heat Meter Heat usage GJ', + 'icon': 'mdi:fire', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_usage_gj', + 'last_changed': , + 'last_updated': , + 'state': '123.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Heat previous year GJ', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_heat_previous_year_gj', + 'last_changed': , + 'last_updated': , + 'state': '111.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'Heat Meter Volume usage previous year', + 'icon': 'mdi:fire', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_volume_usage_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Ownership number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_ownership_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Error number', + 'icon': 'mdi:home-alert', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_error_number', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Device number', + 'icon': 'mdi:identifier', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_device_number', + 'last_changed': , + 'last_updated': , + 'state': 'devicenr_789', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Measurement period minutes', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measurement_period_minutes', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Heat Meter Power max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_power_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Flowrate max previous year', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flowrate_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Return temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_return_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Heat Meter Flow temperature max previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_temperature_max_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Operating hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_operating_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Flow hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_flow_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Heat Meter Fault hours previous year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_fault_hours_previous_year', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Yearly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_yearly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Monthly set day', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_monthly_set_day', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Heat Meter Meter date time', + 'icon': 'mdi:clock-outline', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_meter_date_time', + 'last_changed': , + 'last_updated': , + 'state': '2022-05-20T02:41:17+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Measuring range', + 'icon': 'mdi:water-outline', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.heat_meter_measuring_range', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Heat Meter Settings and firmware', + }), + 'context': , + 'entity_id': 'sensor.heat_meter_settings_and_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index a37fab65a10f..4de58a206e6d 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -4,23 +4,11 @@ import datetime from unittest.mock import patch import serial +from syrupy import SnapshotAssertion from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.landisgyr_heat_meter.const import DOMAIN, POLLING_INTERVAL -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - EntityCategory, - UnitOfEnergy, - UnitOfVolume, -) +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -46,7 +34,10 @@ class MockHeatMeterResponse: @patch(API_HEAT_METER_SERVICE) async def test_create_sensors( - mock_heat_meter, hass: HomeAssistant, entity_registry: er.EntityRegistry + mock_heat_meter, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test sensor.""" entry_data = { @@ -55,7 +46,6 @@ async def test_create_sensors( "device_number": "123456789", } mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, data=entry_data) - mock_entry.add_to_hass(hass) mock_heat_meter_response = MockHeatMeterResponse( @@ -72,37 +62,7 @@ async def test_create_sensors( await async_setup_component(hass, HA_DOMAIN, {}) await hass.async_block_till_done() - # check if 26 attributes have been created - assert len(hass.states.async_all()) == 25 - - state = hass.states.get("sensor.heat_meter_heat_usage_gj") - assert state - assert state.state == "123.0" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.GIGA_JOULE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - - state = hass.states.get("sensor.heat_meter_volume_usage") - assert state - assert state.state == "456.0" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - - state = hass.states.get("sensor.heat_meter_device_number") - assert state - assert state.state == "devicenr_789" - assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_registry.async_get("sensor.heat_meter_device_number") - assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC - - state = hass.states.get("sensor.heat_meter_meter_date_time") - assert state - assert state.attributes.get(ATTR_ICON) == "mdi:clock-outline" - assert state.attributes.get(ATTR_STATE_CLASS) is None - entity_registry_entry = entity_registry.async_get( - "sensor.heat_meter_meter_date_time" - ) - assert entity_registry_entry.entity_category == EntityCategory.DIAGNOSTIC + assert hass.states.async_all() == snapshot @patch(API_HEAT_METER_SERVICE) From 2b4514ae25b795926a5d07b2dc2472a3d8415ef5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Mar 2023 11:53:22 +0100 Subject: [PATCH 0710/1058] Add codeowner Workday (#90167) codeowner --- CODEOWNERS | 4 ++-- homeassistant/components/workday/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index afab5f88856e..617fc46c27c1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1356,8 +1356,8 @@ build.json @home-assistant/supervisor /tests/components/wled/ @frenck /homeassistant/components/wolflink/ @adamkrol93 /tests/components/wolflink/ @adamkrol93 -/homeassistant/components/workday/ @fabaff -/tests/components/workday/ @fabaff +/homeassistant/components/workday/ @fabaff @gjohansson-ST +/tests/components/workday/ @fabaff @gjohansson-ST /homeassistant/components/worldclock/ @fabaff /tests/components/worldclock/ @fabaff /homeassistant/components/ws66i/ @ssaenger diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 442456066c4a..4c1014140690 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -1,7 +1,7 @@ { "domain": "workday", "name": "Workday", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@gjohansson-ST"], "documentation": "https://www.home-assistant.io/integrations/workday", "iot_class": "local_polling", "loggers": [ From dd4a3089ec52f97d9dd0b8a4828362395d078124 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Mar 2023 11:54:15 +0100 Subject: [PATCH 0711/1058] Add constants file for Brottsplatskartan (#90165) * bpk constants * not used --- .../components/brottsplatskartan/const.py | 33 +++++++++++++++++++ .../components/brottsplatskartan/sensor.py | 33 ++----------------- 2 files changed, 35 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/brottsplatskartan/const.py diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py new file mode 100644 index 000000000000..87c42b01f4bb --- /dev/null +++ b/homeassistant/components/brottsplatskartan/const.py @@ -0,0 +1,33 @@ +"""Adds constants for brottsplatskartan integration.""" + +import logging + +LOGGER = logging.getLogger(__package__) + +CONF_AREA = "area" +DEFAULT_NAME = "Brottsplatskartan" + +AREAS = [ + "N/A", + "Blekinge län", + "Dalarnas län", + "Gotlands län", + "Gävleborgs län", + "Hallands län", + "Jämtlands län", + "Jönköpings län", + "Kalmar län", + "Kronobergs län", + "Norrbottens län", + "Skåne län", + "Stockholms län", + "Södermanlands län", + "Uppsala län", + "Värmlands län", + "Västerbottens län", + "Västernorrlands län", + "Västmanlands län", + "Västra Götalands län", + "Örebro län", + "Östergötlands län", +] diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index d76cb7c8a5fa..da53a9fc0eca 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta -import logging import uuid import brottsplatskartan @@ -16,38 +15,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) - -CONF_AREA = "area" - -DEFAULT_NAME = "Brottsplatskartan" +from .const import AREAS, CONF_AREA, DEFAULT_NAME, LOGGER SCAN_INTERVAL = timedelta(minutes=30) -AREAS = [ - "Blekinge län", - "Dalarnas län", - "Gotlands län", - "Gävleborgs län", - "Hallands län", - "Jämtlands län", - "Jönköpings län", - "Kalmar län", - "Kronobergs län", - "Norrbottens län", - "Skåne län", - "Stockholms län", - "Södermanlands län", - "Uppsala län", - "Värmlands län", - "Västerbottens län", - "Västernorrlands län", - "Västmanlands län", - "Västra Götalands län", - "Örebro län", - "Östergötlands län", -] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, @@ -99,7 +70,7 @@ class BrottsplatskartanSensor(SensorEntity): incidents = self._brottsplatskartan.get_incidents() if incidents is False: - _LOGGER.debug("Problems fetching incidents") + LOGGER.debug("Problems fetching incidents") return for incident in incidents: From b1370cbd428ce39a97cb4f66f69030c53be08b25 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Mar 2023 12:01:03 +0100 Subject: [PATCH 0712/1058] Add constants file to workday (#90168) Constants workday --- .../components/workday/binary_sensor.py | 56 +++++++++---------- homeassistant/components/workday/const.py | 25 +++++++++ 2 files changed, 50 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/workday/const.py diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index e66efa039a12..cfd04dd30d14 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import date, timedelta -import logging from typing import Any import holidays @@ -13,31 +12,28 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, BinarySensorEntity, ) -from homeassistant.const import CONF_NAME, WEEKDAYS +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt -_LOGGER = logging.getLogger(__name__) - -ALLOWED_DAYS = WEEKDAYS + ["holiday"] - -CONF_COUNTRY = "country" -CONF_PROVINCE = "province" -CONF_WORKDAYS = "workdays" -CONF_EXCLUDES = "excludes" -CONF_OFFSET = "days_offset" -CONF_ADD_HOLIDAYS = "add_holidays" -CONF_REMOVE_HOLIDAYS = "remove_holidays" - -# By default, Monday - Friday are workdays -DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"] -# By default, public holidays, Saturdays and Sundays are excluded from workdays -DEFAULT_EXCLUDES = ["sat", "sun", "holiday"] -DEFAULT_NAME = "Workday Sensor" -DEFAULT_OFFSET = 0 +from .const import ( + ALLOWED_DAYS, + CONF_ADD_HOLIDAYS, + CONF_COUNTRY, + CONF_EXCLUDES, + CONF_OFFSET, + CONF_PROVINCE, + CONF_REMOVE_HOLIDAYS, + CONF_WORKDAYS, + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, + LOGGER, +) def valid_country(value: Any) -> str: @@ -106,14 +102,14 @@ def setup_platform( ): obj_holidays = getattr(holidays, country)(subdiv=province, years=year) else: - _LOGGER.error("There is no subdivision %s in country %s", province, country) + LOGGER.error("There is no subdivision %s in country %s", province, country) return # Add custom holidays try: obj_holidays.append(add_holidays) except TypeError: - _LOGGER.debug("No custom holidays or invalid holidays") + LOGGER.debug("No custom holidays or invalid holidays") # Remove holidays try: @@ -123,25 +119,23 @@ def setup_platform( if dt.parse_date(remove_holiday): # remove holiday by date removed = obj_holidays.pop(remove_holiday) - _LOGGER.debug("Removed %s", remove_holiday) + LOGGER.debug("Removed %s", remove_holiday) else: # remove holiday by name - _LOGGER.debug("Treating '%s' as named holiday", remove_holiday) + LOGGER.debug("Treating '%s' as named holiday", remove_holiday) removed = obj_holidays.pop_named(remove_holiday) for holiday in removed: - _LOGGER.debug( - "Removed %s by name '%s'", holiday, remove_holiday - ) + LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) except KeyError as unmatched: - _LOGGER.warning("No holiday found matching %s", unmatched) + LOGGER.warning("No holiday found matching %s", unmatched) except TypeError: - _LOGGER.debug("No holidays to remove or invalid holidays") + LOGGER.debug("No holidays to remove or invalid holidays") - _LOGGER.debug("Found the following holidays for your configuration:") + LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): # Make explicit str variable to avoid "Incompatible types in assignment" _holiday_string = holiday_date.strftime("%Y-%m-%d") - _LOGGER.debug("%s %s", _holiday_string, name) + LOGGER.debug("%s %s", _holiday_string, name) add_entities( [IsWorkdaySensor(obj_holidays, workdays, excludes, days_offset, sensor_name)], diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py new file mode 100644 index 000000000000..9ebf85f1c2cd --- /dev/null +++ b/homeassistant/components/workday/const.py @@ -0,0 +1,25 @@ +"""Add constants for Workday integration.""" +from __future__ import annotations + +import logging + +from homeassistant.const import WEEKDAYS + +LOGGER = logging.getLogger(__name__) + +ALLOWED_DAYS = WEEKDAYS + ["holiday"] + +CONF_COUNTRY = "country" +CONF_PROVINCE = "province" +CONF_WORKDAYS = "workdays" +CONF_EXCLUDES = "excludes" +CONF_OFFSET = "days_offset" +CONF_ADD_HOLIDAYS = "add_holidays" +CONF_REMOVE_HOLIDAYS = "remove_holidays" + +# By default, Monday - Friday are workdays +DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"] +# By default, public holidays, Saturdays and Sundays are excluded from workdays +DEFAULT_EXCLUDES = ["sat", "sun", "holiday"] +DEFAULT_NAME = "Workday Sensor" +DEFAULT_OFFSET = 0 From d5f949f4d8a09d9ba31c7859466535e341bf09ee Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 23 Mar 2023 13:42:39 +0100 Subject: [PATCH 0713/1058] Update pydantic to 1.10.7 (#90164) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index e2db9d1e9f73..fa1688152029 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ freezegun==1.2.2 mock-open==1.4.0 mypy==1.1.1 pre-commit==3.1.0 -pydantic==1.10.6 +pydantic==1.10.7 pylint==2.17.0 pylint-per-file-ignores==1.1.0 pipdeptree==2.5.0 From 504793882d50a1f8b878f0f1cc3c81fc9fe97ad7 Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Thu, 23 Mar 2023 10:09:00 -0500 Subject: [PATCH 0714/1058] Remove unecessary variable in control4 (#90176) Remove unecessary expiration value --- homeassistant/components/control4/__init__.py | 2 -- homeassistant/components/control4/const.py | 1 - homeassistant/components/control4/director_utils.py | 10 +--------- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index c99af1f89ce0..de4c8208ee03 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -35,7 +35,6 @@ from .const import ( CONF_DIRECTOR_ALL_ITEMS, CONF_DIRECTOR_MODEL, CONF_DIRECTOR_SW_VERSION, - CONF_DIRECTOR_TOKEN_EXPIRATION, DEFAULT_SCAN_INTERVAL, DOMAIN, ) @@ -79,7 +78,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) entry_data[CONF_DIRECTOR] = director - entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_dict["token_expiration"] # Add Control4 controller to device registry controller_href = (await account.getAccountControllers())["href"] diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py index 275908819852..677610a1618b 100644 --- a/homeassistant/components/control4/const.py +++ b/homeassistant/components/control4/const.py @@ -7,7 +7,6 @@ MIN_SCAN_INTERVAL = 1 CONF_ACCOUNT = "account" CONF_DIRECTOR = "director" -CONF_DIRECTOR_TOKEN_EXPIRATION = "director_token_expiry" CONF_DIRECTOR_SW_VERSION = "director_sw_version" CONF_DIRECTOR_MODEL = "director_model" CONF_DIRECTOR_ALL_ITEMS = "director_all_items" diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index fc4ca9e358d4..bab8c8634cae 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -10,13 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERN from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import ( - CONF_ACCOUNT, - CONF_CONTROLLER_UNIQUE_ID, - CONF_DIRECTOR, - CONF_DIRECTOR_TOKEN_EXPIRATION, - DOMAIN, -) +from .const import CONF_ACCOUNT, CONF_CONTROLLER_UNIQUE_ID, CONF_DIRECTOR, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -53,10 +47,8 @@ async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): director = C4Director( config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session ) - director_token_expiry = director_token_dict["token_expiration"] _LOGGER.debug("Saving new tokens in hass data") entry_data = hass.data[DOMAIN][entry.entry_id] entry_data[CONF_ACCOUNT] = account entry_data[CONF_DIRECTOR] = director - entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_expiry From 73ed6e039ae77c6d7e0b19d1750fae2efedc8314 Mon Sep 17 00:00:00 2001 From: PeteRager <76050312+PeteRager@users.noreply.github.com> Date: Thu, 23 Mar 2023 11:15:55 -0400 Subject: [PATCH 0715/1058] Improve logging for unavailable sonos hosts (#90172) * Repeated warning messages on unavailable manually specified hosts Sonos logs warning messages every 1 minute 12 seconds for hosts that are not on-line. This fixes the issue and the warning will be logged the first time, and subsequent logs messages will be at DEBUG level * Update homeassistant/components/sonos/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Log info message when reconnect succeeds * Use pop to simplify code * Add additional test, fix key error with pop * Use pop with default return value * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/sonos/test_init.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update comment, remove unneeded line of code --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/sonos/__init__.py | 16 ++++- tests/components/sonos/test_init.py | 71 +++++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e181e995c748..4b68030f843c 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -177,6 +177,7 @@ class SonosDiscoveryManager: self.entry = entry self.data = data self.hosts = set(hosts) + self.hosts_in_error: dict[str, bool] = {} self.discovery_lock = asyncio.Lock() self.creation_lock = asyncio.Lock() self._known_invisible: set[SoCo] = set() @@ -353,10 +354,19 @@ class SonosDiscoveryManager: soco, ) except (OSError, SoCoException, Timeout) as ex: - _LOGGER.warning( - "Could not get visible Sonos devices from %s: %s", ip_addr, ex - ) + if not self.hosts_in_error.get(ip_addr): + _LOGGER.warning( + "Could not get visible Sonos devices from %s: %s", ip_addr, ex + ) + self.hosts_in_error[ip_addr] = True + else: + _LOGGER.debug( + "Could not get visible Sonos devices from %s: %s", ip_addr, ex + ) + else: + if self.hosts_in_error.pop(ip_addr, None): + _LOGGER.info("Connection restablished to Sonos device %s", ip_addr) if new_hosts := { x.ip_address for x in visible_zones diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index fc063991e614..6cc79e1b2f0f 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,8 +1,13 @@ """Tests for the Sonos config flow.""" -from unittest.mock import patch +import logging +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import sonos, zeroconf +from homeassistant.components.sonos import SonosDiscoveryManager +from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -63,3 +68,67 @@ async def test_not_configuring_sonos_not_creates_entry(hass: HomeAssistant) -> N await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 0 + + +async def test_async_poll_manual_hosts_warnings( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that host warnings are not logged repeatedly.""" + await async_setup_component( + hass, + sonos.DOMAIN, + {"sonos": {"media_player": {"interface_addr": "127.0.0.1"}}}, + ) + await hass.async_block_till_done() + manager: SonosDiscoveryManager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] + manager.hosts.add("10.10.10.10") + with caplog.at_level(logging.DEBUG), patch.object( + manager, "_async_handle_discovery_message" + ), patch("homeassistant.components.sonos.async_call_later"), patch( + "homeassistant.components.sonos.async_dispatcher_send" + ), patch.object( + hass, "async_add_executor_job", new=AsyncMock() + ) as mock_async_add_executor_job: + mock_async_add_executor_job.side_effect = [ + OSError(), + OSError(), + [], + [], + OSError(), + ] + # First call fails, it should be logged as a WARNING message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "WARNING" + assert "Could not get visible Sonos devices from" in record.message + + # Second call fails again, it should be logged as a DEBUG message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "DEBUG" + assert "Could not get visible Sonos devices from" in record.message + + # Third call succeeds, it should log an info message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "INFO" + assert "Connection restablished to Sonos device" in record.message + + # Fourth call succeeds again, no need to log + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 0 + + # Fifth call fail again again, should be logged as a WARNING message + caplog.clear() + await manager.async_poll_manual_hosts() + assert len(caplog.messages) == 1 + record = caplog.records[0] + assert record.levelname == "WARNING" + assert "Could not get visible Sonos devices from" in record.message From cb578c71e03e0187b5fc6a516e5d9ec11421a8e1 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 23 Mar 2023 17:21:21 +0100 Subject: [PATCH 0716/1058] Add Landis+Gyr missing device class (#90182) Add missing device class for heat_previous_year_gj --- homeassistant/components/landisgyr_heat_meter/sensor.py | 1 + tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index af9662974212..244515a07d4c 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -77,6 +77,7 @@ HEAT_METER_SENSOR_TYPES = ( icon="mdi:fire", name="Heat previous year GJ", native_unit_of_measurement=UnitOfEnergy.GIGA_JOULE, + device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda res: getattr(res, "heat_previous_year_gj", None), ), diff --git a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr index e149073d9c88..9c62ca3f94ba 100644 --- a/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr +++ b/tests/components/landisgyr_heat_meter/snapshots/test_sensor.ambr @@ -31,6 +31,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'energy', 'friendly_name': 'Heat Meter Heat previous year GJ', 'icon': 'mdi:fire', 'unit_of_measurement': , From e290febb384d7757f96febdf285bbdc817e255ab Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Thu, 23 Mar 2023 11:34:38 -0500 Subject: [PATCH 0717/1058] Bump pyControl4 to 1.1.0 (#90115) * Bump pyControl4 to 1.1.0 * Remove mock token_expiration from control4 --- homeassistant/components/control4/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/control4/test_config_flow.py | 6 +----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json index 125e3c2e38fe..765f0dce78ce 100644 --- a/homeassistant/components/control4/manifest.json +++ b/homeassistant/components/control4/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/control4", "iot_class": "local_polling", "loggers": ["pyControl4"], - "requirements": ["pyControl4==0.0.6"], + "requirements": ["pyControl4==1.1.0"], "ssdp": [ { "st": "c4:director" diff --git a/requirements_all.txt b/requirements_all.txt index 34e470760742..7d1670833f23 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1457,7 +1457,7 @@ py17track==2021.12.2 pyCEC==0.5.2 # homeassistant.components.control4 -pyControl4==0.0.6 +pyControl4==1.1.0 # homeassistant.components.met_eireann pyMetEireann==2021.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbc282040f3a..3bb56a1be494 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1066,7 +1066,7 @@ py17track==2021.12.2 pyCEC==0.5.2 # homeassistant.components.control4 -pyControl4==0.0.6 +pyControl4==1.1.0 # homeassistant.components.met_eireann pyMetEireann==2021.8.0 diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py index a40047eccd13..4909ead6c489 100644 --- a/tests/components/control4/test_config_flow.py +++ b/tests/components/control4/test_config_flow.py @@ -1,5 +1,4 @@ """Test the Control4 config flow.""" -import datetime from unittest.mock import AsyncMock, patch from pyControl4.account import C4Account @@ -25,10 +24,7 @@ def _get_mock_c4_account( "href": "https://apis.control4.com/account/v3/rest/accounts/000000", "name": "Name", }, - getDirectorBearerToken={ - "token": "token", - "token_expiration": datetime.datetime(2020, 7, 15, 13, 50, 15, 26940), - }, + getDirectorBearerToken={"token": "token"}, ): c4_account_mock = AsyncMock(C4Account) From 87475e8ff6e4b5cb9ad01bf5bdc59115ad0772b3 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Thu, 23 Mar 2023 17:34:57 +0100 Subject: [PATCH 0718/1058] Cleanup some leftovers for Landis+Gyr (#90183) Cleanup some leftovers --- homeassistant/components/landisgyr_heat_meter/const.py | 1 - homeassistant/components/landisgyr_heat_meter/manifest.json | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/const.py b/homeassistant/components/landisgyr_heat_meter/const.py index 56f5980a839d..079bcad25348 100644 --- a/homeassistant/components/landisgyr_heat_meter/const.py +++ b/homeassistant/components/landisgyr_heat_meter/const.py @@ -4,6 +4,5 @@ from datetime import timedelta DOMAIN = "landisgyr_heat_meter" -GJ_TO_MWH = 0.277778 # conversion factor ULTRAHEAT_TIMEOUT = 30 # reading the IR port can take some time POLLING_INTERVAL = timedelta(days=1) # Polling is only daily to prevent battery drain. diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index 5e10f3941865..a056f1f65645 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -5,9 +5,6 @@ "config_flow": true, "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", - "homekit": {}, "iot_class": "local_polling", - "requirements": ["ultraheat-api==0.5.1"], - "ssdp": [], - "zeroconf": [] + "requirements": ["ultraheat-api==0.5.1"] } From 8fd88d6703ce2d6f9b4235e2fd9015774abc2ca4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:13:36 +0100 Subject: [PATCH 0719/1058] Prepare MQTT platform tests part2 (#90105) * Tests button * Tests camera * Tests climate --- tests/components/mqtt/test_button.py | 88 +-- tests/components/mqtt/test_camera.py | 81 ++- tests/components/mqtt/test_climate.py | 902 ++++++++++++++++---------- 3 files changed, 650 insertions(+), 421 deletions(-) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index cdb3d0fbf382..37636ff4bfd9 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -13,7 +13,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -57,13 +56,9 @@ def button_platform_only(): @pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") -async def test_sending_mqtt_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: { @@ -74,10 +69,14 @@ async def test_sending_mqtt_commands( "qos": "2", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test_button") assert state.state == STATE_UNKNOWN @@ -98,13 +97,9 @@ async def test_sending_mqtt_commands( assert state.state == "2021-11-08T13:31:44+00:00" -async def test_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of MQTT commands through a command template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: { @@ -114,10 +109,14 @@ async def test_command_template( "payload_press": "milky_way_press", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_command_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of MQTT commands through a command template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test") assert state.state == STATE_UNKNOWN @@ -436,11 +435,9 @@ async def test_entity_debug_info_message( ) -async def test_invalid_device_class(hass: HomeAssistant) -> None: - """Test device_class option with invalid value.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: { @@ -449,17 +446,20 @@ async def test_invalid_device_class(hass: HomeAssistant) -> None: "device_class": "foobarnotreal", } } - }, - ) - - -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test device_class option with valid values.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test device_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { button.DOMAIN: [ @@ -479,10 +479,14 @@ async def test_valid_device_class( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test device_class option with valid values.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test_1") assert state.attributes["device_class"] == button.ButtonDeviceClass.UPDATE diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 90020bce489f..27d575ce4e8b 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -10,7 +10,6 @@ from homeassistant.components import camera, mqtt from homeassistant.components.mqtt.camera import MQTT_CAMERA_ATTRIBUTES_BLOCKED from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -56,20 +55,18 @@ def camera_platform_only(): yield +@pytest.mark.parametrize( + "hass_config", + [{mqtt.DOMAIN: {camera.DOMAIN: {"topic": "test/camera", "name": "Test Camera"}}}], +) async def test_run_camera_setup( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test that it fetches the given payload.""" topic = "test/camera" - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {camera.DOMAIN: {"topic": topic, "name": "Test Camera"}}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -82,28 +79,28 @@ async def test_run_camera_setup( assert body == "beer" -async def test_run_camera_b64_encoded( - hass: HomeAssistant, - hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test that it fetches the given encoded payload.""" - topic = "test/camera" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { camera.DOMAIN: { - "topic": topic, + "topic": "test/camera", "name": "Test Camera", "image_encoding": "b64", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_camera_b64_encoded( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test that it fetches the given encoded payload.""" + topic = "test/camera" + await mqtt_mock_entry_no_yaml_config() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -116,31 +113,31 @@ async def test_run_camera_b64_encoded( assert body == "grass" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + "camera": { + "topic": "test/camera", + "name": "Test Camera", + "encoding": "utf-8", + "image_encoding": "b64", + "availability": {"topic": "test/camera_availability"}, + } + } + } + ], +) async def test_camera_b64_encoded_with_availability( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test availability works if b64 encoding is turned on.""" topic = "test/camera" topic_availability = "test/camera_availability" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - "camera": { - "topic": topic, - "name": "Test Camera", - "encoding": "utf-8", - "image_encoding": "b64", - "availability": {"topic": topic_availability}, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # Make sure we are available async_fire_mqtt_message(hass, topic_availability, "online") diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9a9a9d81e8ec..36894ba2cdc7 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -30,9 +30,9 @@ from homeassistant.components.climate import ( from homeassistant.components.mqtt.climate import MQTT_CLIMATE_ATTRIBUTES_BLOCKED from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -102,13 +102,12 @@ def climate_platform_only(): yield +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_setup_params( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the initial parameters.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -121,49 +120,101 @@ async def test_setup_params( assert state.attributes.get("max_humidity") == DEFAULT_MAX_HUMIDITY +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"preset_modes": ["auto", "home", "none"]},), + ) + ], +) async def test_preset_none_in_preset_modes( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test the preset mode payload reset configuration.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) - config["preset_modes"].append("none") - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {climate.DOMAIN: config}} - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [mqtt]: not a valid value" in caplog.text @pytest.mark.parametrize( - ("parameter", "config_value"), + ("hass_config", "parameter"), [ - ("away_mode_command_topic", "away-mode-command-topic"), - ("away_mode_state_topic", "away-mode-state-topic"), - ("away_mode_state_template", "{{ value_json }}"), - ("hold_mode_command_topic", "hold-mode-command-topic"), - ("hold_mode_command_template", "hold-mode-command-template"), - ("hold_mode_state_topic", "hold-mode-state-topic"), - ("hold_mode_state_template", "{{ value_json }}"), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"away_mode_command_topic": "away-mode-command-topic"},), + ), + "away_mode_command_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"away_mode_state_topic": "away-mode-state-topic"},), + ), + "away_mode_state_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"away_mode_state_template": "{{ value_json }}"},), + ), + "away_mode_state_template", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_command_topic": "hold-mode-command-topic"},), + ), + "hold_mode_command_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_command_template": "hold-mode-command-template"},), + ), + "hold_mode_command_template", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_state_topic": "hold-mode-state-topic"},), + ), + "hold_mode_state_topic", + ), + ( + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"hold_mode_state_template": "{{ value_json }}"},), + ), + "hold_mode_state_template", + ), ], ) async def test_preset_modes_deprecation_guard( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, parameter, config_value + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, parameter: str ) -> None: """Test the configuration for invalid legacy parameters.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][climate.DOMAIN]) - config[parameter] = config_value - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {climate.DOMAIN: config}} - ) assert f"[{parameter}] is an invalid option for [mqtt]. Check: mqtt->mqtt->climate->0->{parameter}" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the supported_features.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) support = ( @@ -179,13 +230,12 @@ async def test_supported_features( assert state.attributes.get("supported_features") == support +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_get_hvac_modes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the operation list returns the correct modes.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) modes = state.attributes.get("hvac_modes") @@ -199,18 +249,17 @@ async def test_get_hvac_modes( ] == modes +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_operation_bad_attr_and_state( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting operation mode without required attribute. Also check the state. """ - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -224,13 +273,12 @@ async def test_set_operation_bad_attr_and_state( assert state.state == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_operation( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new operation mode.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -241,15 +289,20 @@ async def test_set_operation( mqtt_mock.async_publish.assert_called_once_with("mode-topic", "cool", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"mode_state_topic": "mode-state"},) + ) + ], +) async def test_set_operation_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting operation mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["mode_state_topic"] = "mode-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "unknown" @@ -267,16 +320,21 @@ async def test_set_operation_pessimistic( assert state.state == "cool" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"mode_state_topic": "mode-state", "optimistic": True},), + ) + ], +) async def test_set_operation_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting operation mode in optimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["mode_state_topic"] = "mode-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -297,15 +355,19 @@ async def test_set_operation_optimistic( # CONF_POWER_COMMAND_TOPIC, CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, # support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added # support was deprecated with release 2023.2 and will be removed with release 2023.8 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"power_command_topic": "power-command"},) + ) + ], +) async def test_set_operation_with_power_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new operation mode with power command enabled.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["power_command_topic"] = "power-command" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -326,15 +388,14 @@ async def test_set_operation_with_power_command( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_fan_mode_bad_attr( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting fan mode without required attribute.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -347,15 +408,19 @@ async def test_set_fan_mode_bad_attr( assert state.attributes.get("fan_mode") == "low" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"fan_mode_state_topic": "fan-state"},) + ) + ], +) async def test_set_fan_mode_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["fan_mode_state_topic"] = "fan-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") is None @@ -373,16 +438,21 @@ async def test_set_fan_mode_pessimistic( assert state.attributes.get("fan_mode") == "high" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"fan_mode_state_topic": "fan-state", "optimistic": True},), + ) + ], +) async def test_set_fan_mode_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode in optimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["fan_mode_state_topic"] = "fan-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -400,13 +470,12 @@ async def test_set_fan_mode_optimistic( assert state.attributes.get("fan_mode") == "low" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_fan_mode( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new fan mode.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -416,15 +485,14 @@ async def test_set_fan_mode( assert state.attributes.get("fan_mode") == "high" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing_mode_bad_attr( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting swing mode without required attribute.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -437,15 +505,19 @@ async def test_set_swing_mode_bad_attr( assert state.attributes.get("swing_mode") == "off" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"swing_mode_state_topic": "swing-state"},) + ) + ], +) async def test_set_swing_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting swing mode in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["swing_mode_state_topic"] = "swing-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None @@ -463,16 +535,21 @@ async def test_set_swing_pessimistic( assert state.attributes.get("swing_mode") == "on" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"swing_mode_state_topic": "swing-state", "optimistic": True},), + ) + ], +) async def test_set_swing_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting swing mode in optimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["swing_mode_state_topic"] = "swing-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -490,13 +567,12 @@ async def test_set_swing_optimistic( assert state.attributes.get("swing_mode") == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_swing( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of new swing mode.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -506,13 +582,12 @@ async def test_set_swing( assert state.attributes.get("swing_mode") == "on" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -545,13 +620,12 @@ async def test_set_target_temperature( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_humidity( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -562,15 +636,21 @@ async def test_set_target_humidity( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state"},), + ) + ], +) async def test_set_target_temperature_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_state_topic"] = "temperature-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") is None @@ -588,16 +668,21 @@ async def test_set_target_temperature_pessimistic( assert state.attributes.get("temperature") == 1701 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"temperature_state_topic": "temperature-state", "optimistic": True},), + ) + ], +) async def test_set_target_temperature_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target temperature optimistic.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_state_topic"] = "temperature-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -615,13 +700,12 @@ async def test_set_target_temperature_optimistic( assert state.attributes.get("temperature") == 18 +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature_low_high( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, target_temp_low=20, target_temp_high=23, entity_id=ENTITY_CLIMATE @@ -633,16 +717,26 @@ async def test_set_target_temperature_low_high( mqtt_mock.async_publish.assert_any_call("temperature-high-topic", "23.0", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_low_state_topic": "temperature-low-state", + "temperature_high_state_topic": "temperature-high-state", + }, + ), + ) + ], +) async def test_set_target_temperature_low_highpessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_low_state_topic"] = "temperature-low-state" - config["climate"]["temperature_high_state_topic"] = "temperature-high-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("target_temp_low") is None @@ -673,17 +767,27 @@ async def test_set_target_temperature_low_highpessimistic( assert state.attributes.get("target_temp_high") == 1703 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_low_state_topic": "temperature-low-state", + "temperature_high_state_topic": "temperature-high-state", + "optimistic": True, + }, + ), + ) + ], +) async def test_set_target_temperature_low_high_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the low/high target temperature optimistic.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["optimistic"] = True - config["climate"]["temperature_low_state_topic"] = "temperature-low-state" - config["climate"]["temperature_high_state_topic"] = "temperature-high-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("target_temp_low") == 21 @@ -714,16 +818,21 @@ async def test_set_target_temperature_low_high_optimistic( assert state.attributes.get("target_temp_high") == 25 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"target_humidity_state_topic": "humidity-state", "optimistic": True},), + ) + ], +) async def test_set_target_humidity_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity optimistic.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["target_humidity_state_topic"] = "humidity-state" - config["climate"]["optimistic"] = True - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -740,15 +849,21 @@ async def test_set_target_humidity_optimistic( assert state.attributes.get("humidity") == 53 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"target_humidity_state_topic": "humidity-state"},), + ) + ], +) async def test_set_target_humidity_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["target_humidity_state_topic"] = "humidity-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -765,45 +880,63 @@ async def test_set_target_humidity_pessimistic( assert state.attributes.get("humidity") == 80 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"current_temperature_topic": "current_temperature"},), + ) + ], +) async def test_receive_mqtt_temperature( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test getting the current temperature via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["current_temperature_topic"] = "current_temperature" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "current_temperature", "47") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("current_temperature") == 47 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"current_humidity_topic": "current_humidity"},), + ) + ], +) async def test_receive_mqtt_humidity( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test getting the current humidity via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["current_humidity_topic"] = "current_humidity" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "current_humidity", "35") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("current_humidity") == 35 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"target_humidity_state_topic": "humidity-state"},), + ) + ], +) async def test_handle_target_humidity_received( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting the target humidity via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["target_humidity_state_topic"] = "humidity-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("humidity") is None @@ -814,15 +947,15 @@ async def test_handle_target_humidity_received( assert state.attributes.get("humidity") == 65 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"action_topic": "action"},))], +) async def test_handle_action_received( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test getting the action received via MQTT.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["action_topic"] = "action" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # Cycle through valid modes and also check for wrong input such as "None" (str(None)) async_fire_mqtt_message(hass, "action", "None") @@ -839,16 +972,14 @@ async def test_handle_action_received( assert hvac_action == action +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_preset_mode_optimistic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -889,18 +1020,23 @@ async def test_set_preset_mode_optimistic( assert "'invalid' is not a valid preset mode" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"preset_mode_state_topic": "preset-mode-state", "optimistic": True},), + ) + ], +) async def test_set_preset_mode_explicit_optimistic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["optimistic"] = True - config["climate"]["preset_mode_state_topic"] = "preset-mode-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -941,17 +1077,23 @@ async def test_set_preset_mode_explicit_optimistic( assert "'invalid' is not a valid preset mode" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ({"preset_mode_state_topic": "preset-mode-state"},), + ) + ], +) async def test_set_preset_mode_pessimistic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting of the preset mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["preset_mode_state_topic"] = "preset-mode-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -990,15 +1132,19 @@ async def test_set_preset_mode_pessimistic( assert state.attributes.get("preset_mode") == "home" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"aux_state_topic": "aux-state"},) + ) + ], +) async def test_set_aux_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of the aux heating in pessimistic mode.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["aux_state_topic"] = "aux-state" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -1020,13 +1166,12 @@ async def test_set_aux_pessimistic( assert state.attributes.get("aux_heat") == "off" +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_aux( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting of the aux heating.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -1080,21 +1225,30 @@ async def test_custom_availability_payload( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_low_state_topic": "temperature-state", + "temperature_high_state_topic": "temperature-state", + "temperature_low_state_template": "{{ value_json.temp_low }}", + "temperature_high_state_template": "{{ value_json.temp_high }}", + }, + ), + ) + ], +) async def test_get_target_temperature_low_high_with_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test getting temperature high/low with templates.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_low_state_topic"] = "temperature-state" - config["climate"]["temperature_high_state_topic"] = "temperature-state" - config["climate"]["temperature_low_state_template"] = "{{ value_json.temp_low }}" - config["climate"]["temperature_high_state_template"] = "{{ value_json.temp_high }}" - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) @@ -1148,34 +1302,60 @@ async def test_get_target_temperature_low_high_with_templates( assert "Could not parse temperature_high_state_template from" not in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "target_humidity_command_topic": "humidity-topic", + "temperature_command_topic": "temperature-topic", + "temperature_low_command_topic": "temperature-low-topic", + "temperature_high_command_topic": "temperature-high-topic", + "fan_mode_command_topic": "fan-mode-topic", + "swing_mode_command_topic": "swing-mode-topic", + "aux_command_topic": "aux-topic", + "preset_mode_command_topic": "preset-mode-topic", + "preset_modes": [ + "eco", + "away", + "boost", + "comfort", + "home", + "sleep", + "activity", + ], + # By default, just unquote the JSON-strings + "value_template": "{{ value_json }}", + "action_template": "{{ value_json }}", + # Rendering to a bool for aux heat + "aux_state_template": "{{ value == 'switchmeon' }}", + # Rendering preset_mode + "preset_mode_value_template": "{{ value_json.attribute }}", + "action_topic": "action", + "mode_state_topic": "mode-state", + "fan_mode_state_topic": "fan-state", + "swing_mode_state_topic": "swing-state", + "temperature_state_topic": "temperature-state", + "target_humidity_state_topic": "humidity-state", + "aux_state_topic": "aux-state", + "current_temperature_topic": "current-temperature", + "current_humidity_topic": "current-humidity", + "preset_mode_state_topic": "current-preset-mode", + } + } + } + ], +) async def test_get_with_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test getting various attributes with templates.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - # By default, just unquote the JSON-strings - config["climate"]["value_template"] = "{{ value_json }}" - config["climate"]["action_template"] = "{{ value_json }}" - # Rendering to a bool for aux heat - config["climate"]["aux_state_template"] = "{{ value == 'switchmeon' }}" - # Rendering preset_mode - config["climate"]["preset_mode_value_template"] = "{{ value_json.attribute }}" - - config["climate"]["action_topic"] = "action" - config["climate"]["mode_state_topic"] = "mode-state" - config["climate"]["fan_mode_state_topic"] = "fan-state" - config["climate"]["swing_mode_state_topic"] = "swing-state" - config["climate"]["temperature_state_topic"] = "temperature-state" - config["climate"]["target_humidity_state_topic"] = "humidity-state" - config["climate"]["aux_state_topic"] = "aux-state" - config["climate"]["current_temperature_topic"] = "current-temperature" - config["climate"]["current_humidity_topic"] = "current-humidity" - config["climate"]["preset_mode_state_topic"] = "current-preset-mode" - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # Operation Mode state = hass.states.get(ENTITY_CLIMATE) @@ -1286,26 +1466,52 @@ async def test_get_with_templates( assert state.attributes.get("hvac_action") == "cooling" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test", + "mode_command_topic": "mode-topic", + "target_humidity_command_topic": "humidity-topic", + "temperature_command_topic": "temperature-topic", + "temperature_low_command_topic": "temperature-low-topic", + "temperature_high_command_topic": "temperature-high-topic", + "fan_mode_command_topic": "fan-mode-topic", + "swing_mode_command_topic": "swing-mode-topic", + "aux_command_topic": "aux-topic", + "preset_mode_command_topic": "preset-mode-topic", + "preset_modes": [ + "eco", + "away", + "boost", + "comfort", + "home", + "sleep", + "activity", + ], + # Create simple templates + "fan_mode_command_template": "fan_mode: {{ value }}", + "preset_mode_command_template": "preset_mode: {{ value }}", + "mode_command_template": "mode: {{ value }}", + "swing_mode_command_template": "swing_mode: {{ value }}", + "temperature_command_template": "temp: {{ value }}", + "temperature_high_command_template": "temp_hi: {{ value }}", + "temperature_low_command_template": "temp_lo: {{ value }}", + "target_humidity_command_template": "humidity: {{ value }}", + } + } + } + ], +) async def test_set_and_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test setting various attributes with templates.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - # Create simple templates - config["climate"]["fan_mode_command_template"] = "fan_mode: {{ value }}" - config["climate"]["preset_mode_command_template"] = "preset_mode: {{ value }}" - config["climate"]["mode_command_template"] = "mode: {{ value }}" - config["climate"]["swing_mode_command_template"] = "swing_mode: {{ value }}" - config["climate"]["temperature_command_template"] = "temp: {{ value }}" - config["climate"]["temperature_high_command_template"] = "temp_hi: {{ value }}" - config["climate"]["temperature_low_command_template"] = "temp_lo: {{ value }}" - config["climate"]["target_humidity_command_template"] = "humidity: {{ value }}" - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # Fan Mode await common.async_set_fan_mode(hass, "high", ENTITY_CLIMATE) @@ -1378,16 +1584,15 @@ async def test_set_and_templates( assert state.attributes.get("humidity") == 82 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"min_temp": 26},))], +) async def test_min_temp_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom min temp.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["min_temp"] = 26 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) min_temp = state.attributes.get("min_temp") @@ -1396,16 +1601,15 @@ async def test_min_temp_custom( assert state.attributes.get("min_temp") == 26 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"max_temp": 60},))], +) async def test_max_temp_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom max temp.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["max_temp"] = 60 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) max_temp = state.attributes.get("max_temp") @@ -1414,16 +1618,15 @@ async def test_max_temp_custom( assert max_temp == 60 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"min_humidity": 42},))], +) async def test_min_humidity_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom min humidity.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["min_humidity"] = 42 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) min_humidity = state.attributes.get("min_humidity") @@ -1432,16 +1635,15 @@ async def test_min_humidity_custom( assert state.attributes.get("min_humidity") == 42 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"max_humidity": 58},))], +) async def test_max_humidity_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom max humidity.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["max_humidity"] = 58 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) max_humidity = state.attributes.get("max_humidity") @@ -1450,16 +1652,15 @@ async def test_max_humidity_custom( assert max_humidity == 58 +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"temp_step": 0.01},))], +) async def test_temp_step_custom( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test a custom temp step.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temp_step"] = 0.01 - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) temp_step = state.attributes.get("target_temp_step") @@ -1468,17 +1669,26 @@ async def test_temp_step_custom( assert temp_step == 0.01 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, + DEFAULT_CONFIG, + ( + { + "temperature_unit": "F", + "current_temperature_topic": "current_temperature", + }, + ), + ) + ], +) async def test_temperature_unit( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting temperature unit converts temperature values.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["temperature_unit"] = "F" - config["climate"]["current_temperature_topic"] = "current_temperature" - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "current_temperature", "77") @@ -1783,13 +1993,12 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_precision_default( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting precision to tenths works as intended.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1799,15 +2008,15 @@ async def test_precision_default( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"precision": 0.5},))], +) async def test_precision_halves( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting precision to halves works as intended.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["precision"] = 0.5 - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1817,15 +2026,15 @@ async def test_precision_halves( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [help_custom_config(climate.DOMAIN, DEFAULT_CONFIG, ({"precision": 1.0},))], +) async def test_precision_whole( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that setting precision to whole works as intended.""" - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN]) - config["climate"]["precision"] = 1.0 - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1950,61 +2159,80 @@ async def test_publishing_with_custom_encoding( @pytest.mark.parametrize( - ("config", "valid"), + ("hass_config", "valid"), [ ( { - "name": "test_valid_humidity_min_max", - "min_humidity": 20, - "max_humidity": 80, + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_valid_humidity_min_max", + "min_humidity": 20, + "max_humidity": 80, + }, + } }, True, ), ( { - "name": "test_invalid_humidity_min_max_1", - "min_humidity": 0, - "max_humidity": 101, + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_invalid_humidity_min_max_1", + "min_humidity": 0, + "max_humidity": 101, + }, + } }, False, ), ( { - "name": "test_invalid_humidity_min_max_2", - "max_humidity": 20, - "min_humidity": 40, + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_invalid_humidity_min_max_2", + "max_humidity": 20, + "min_humidity": 40, + }, + } }, False, ), ( { - "name": "test_valid_humidity_state", - "target_humidity_state_topic": "humidity-state", - "target_humidity_command_topic": "humidity-command", + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_valid_humidity_state", + "target_humidity_state_topic": "humidity-state", + "target_humidity_command_topic": "humidity-command", + }, + } }, True, ), ( { - "name": "test_invalid_humidity_state", - "target_humidity_state_topic": "humidity-state", + mqtt.DOMAIN: { + climate.DOMAIN: { + "name": "test_invalid_humidity_state", + "target_humidity_state_topic": "humidity-state", + }, + } }, False, ), ], ) async def test_humidity_configuration_validity( - hass: HomeAssistant, config, valid + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + valid: bool, ) -> None: """Test the validity of humidity configurations.""" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {climate.DOMAIN: config}}, - ) - is valid - ) + if valid: + await mqtt_mock_entry_no_yaml_config() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() async def test_reloadable( From 44add1dc1101d91e641489bfa8c1bae13fb6f655 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:14:08 +0100 Subject: [PATCH 0720/1058] Prepare MQTT platform tests part3 (#90106) * Tests cover * Tests fan --- tests/components/mqtt/test_cover.py | 1557 ++++++++++++++------------- tests/components/mqtt/test_fan.py | 551 +++++----- 2 files changed, 1133 insertions(+), 975 deletions(-) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index a09edcd25e04..dd28d0919bd8 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -45,7 +45,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -91,13 +90,9 @@ def cover_platform_only(): yield -async def test_state_via_state_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -110,10 +105,14 @@ async def test_state_via_state_topic( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_state_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -130,13 +129,9 @@ async def test_state_via_state_topic( assert state.state == STATE_OPEN -async def test_opening_and_closing_state_via_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling opening and closing state via a custom payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -151,10 +146,14 @@ async def test_opening_and_closing_state_via_custom_state_payload( "state_closing": "--43", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_opening_and_closing_state_via_custom_state_payload( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling opening and closing state via a custom payload.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -176,13 +175,9 @@ async def test_opening_and_closing_state_via_custom_state_payload( assert state.state == STATE_CLOSED -async def test_open_closed_state_from_position_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the state after setting the position using optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -196,10 +191,14 @@ async def test_open_closed_state_from_position_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_open_closed_state_from_position_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the state after setting the position using optimistic mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -227,13 +226,9 @@ async def test_open_closed_state_from_position_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_position_via_position_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -248,10 +243,14 @@ async def test_position_via_position_topic( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -268,13 +267,9 @@ async def test_position_via_position_topic( assert state.state == STATE_OPEN -async def test_state_via_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -290,10 +285,14 @@ async def test_state_via_template( {% endif %}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -309,13 +308,9 @@ async def test_state_via_template( assert state.state == STATE_CLOSED -async def test_state_via_template_and_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -331,10 +326,14 @@ async def test_state_via_template_and_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_template_and_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -352,15 +351,9 @@ async def test_state_via_template_and_entity_id( assert state.state == STATE_CLOSED -async def test_state_via_template_with_json_value( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic with JSON value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -371,10 +364,16 @@ async def test_state_via_template_with_json_value( "value_template": "{{ value_json.Var1 }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_via_template_with_json_value( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic with JSON value.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -397,13 +396,9 @@ async def test_state_via_template_with_json_value( ) in caplog.text -async def test_position_via_template_and_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -419,10 +414,14 @@ async def test_position_via_template_and_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_template_and_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -443,30 +442,77 @@ async def test_position_via_template_and_entity_id( @pytest.mark.parametrize( - ("config", "assumed_state"), + ("hass_config", "assumed_state"), [ - ({"command_topic": "abc"}, True), - ({"command_topic": "abc", "state_topic": "abc"}, False), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: {"name": "test", "qos": 0, "command_topic": "abc"} + } + }, + True, + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "command_topic": "abc", + "state_topic": "abc", + } + } + }, + False, + ), # ({"set_position_topic": "abc"}, True), - not a valid configuration - ({"set_position_topic": "abc", "position_topic": "abc"}, False), - ({"tilt_command_topic": "abc"}, True), - ({"tilt_command_topic": "abc", "tilt_status_topic": "abc"}, False), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "set_position_topic": "abc", + "position_topic": "abc", + } + } + }, + False, + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "tilt_command_topic": "abc", + } + } + }, + True, + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "qos": 0, + "tilt_command_topic": "abc", + "tilt_status_topic": "abc", + } + } + }, + False, + ), ], ) async def test_optimistic_flag( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - config, - assumed_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + assumed_state: bool, ) -> None: """Test assumed_state is set correctly.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {cover.DOMAIN: {**config, "name": "test", "qos": 0}}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -476,13 +522,9 @@ async def test_optimistic_flag( assert ATTR_ASSUMED_STATE not in state.attributes -async def test_optimistic_state_change( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test changing state optimistically.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -491,10 +533,14 @@ async def test_optimistic_state_change( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_optimistic_state_change( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test changing state optimistically.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -536,13 +582,9 @@ async def test_optimistic_state_change( assert state.state == STATE_CLOSED -async def test_optimistic_state_change_with_position( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test changing state optimistically.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -553,10 +595,14 @@ async def test_optimistic_state_change_with_position( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_optimistic_state_change_with_position( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test changing state optimistically.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -603,13 +649,9 @@ async def test_optimistic_state_change_with_position( assert state.attributes.get(ATTR_CURRENT_POSITION) == 0 -async def test_send_open_cover_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of open_cover.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -619,10 +661,14 @@ async def test_send_open_cover_command( "qos": 2, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_send_open_cover_command( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of open_cover.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -636,13 +682,9 @@ async def test_send_open_cover_command( assert state.state == STATE_UNKNOWN -async def test_send_close_cover_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of close_cover.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -652,10 +694,14 @@ async def test_send_close_cover_command( "qos": 2, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_send_close_cover_command( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of close_cover.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -669,13 +715,9 @@ async def test_send_close_cover_command( assert state.state == STATE_UNKNOWN -async def test_send_stop__cover_command( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of stop_cover.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -685,10 +727,14 @@ async def test_send_stop__cover_command( "qos": 2, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_send_stop_cover_command( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of stop_cover.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -702,13 +748,9 @@ async def test_send_stop__cover_command( assert state.state == STATE_UNKNOWN -async def test_current_cover_position( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the current cover position.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -722,10 +764,14 @@ async def test_current_cover_position( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_current_cover_position( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the current cover position.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -757,13 +803,9 @@ async def test_current_cover_position( assert current_cover_position == 100 -async def test_current_cover_position_inverted( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the current cover position.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -777,10 +819,14 @@ async def test_current_cover_position_inverted( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_current_cover_position_inverted( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the current cover position.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -823,13 +869,9 @@ async def test_current_cover_position_inverted( assert hass.states.get("cover.test").state == STATE_CLOSED -async def test_optimistic_position( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test optimistic position is not supported.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -838,21 +880,26 @@ async def test_optimistic_position( "set_position_topic": "set-position-topic", } } - }, - ) + } + ], +) +async def test_optimistic_position( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic position is not supported.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: 'set_position_topic' must be set together with 'position_topic'" in caplog.text ) -async def test_position_update( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test cover position update from received MQTT message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -867,10 +914,14 @@ async def test_position_update( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_update( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test cover position update from received MQTT message.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -888,39 +939,58 @@ async def test_position_update( @pytest.mark.parametrize( - ("pos_template", "pos_call", "pos_message"), - [("{{position-1}}", 43, "42"), ("{{100-62}}", 100, "38")], + ("hass_config", "pos_call", "pos_message"), + [ + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "position_open": 100, + "position_closed": 0, + "set_position_topic": "set-position-topic", + "set_position_template": "{{position-1}}", + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + } + } + }, + 43, + "42", + ), + ( + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "position_topic": "get-position-topic", + "command_topic": "command-topic", + "position_open": 100, + "position_closed": 0, + "set_position_topic": "set-position-topic", + "set_position_template": "{{100-62}}", + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + } + } + }, + 100, + "38", + ), + ], ) async def test_set_position_templated( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - pos_template, - pos_call, - pos_message, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + pos_call: int, + pos_message: str, ) -> None: """Test setting cover position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - cover.DOMAIN: { - "name": "test", - "position_topic": "get-position-topic", - "command_topic": "command-topic", - "position_open": 100, - "position_closed": 0, - "set_position_topic": "set-position-topic", - "set_position_template": pos_template, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -934,13 +1004,9 @@ async def test_set_position_templated( ) -async def test_set_position_templated_and_attributes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover position via template and using entities attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -965,10 +1031,14 @@ async def test_set_position_templated_and_attributes( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_position_templated_and_attributes( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover position via template and using entities attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -980,13 +1050,9 @@ async def test_set_position_templated_and_attributes( mqtt_mock.async_publish.assert_called_once_with("set-position-topic", "5", 0, False) -async def test_set_tilt_templated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover tilt position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1004,10 +1070,14 @@ async def test_set_tilt_templated( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_tilt_templated( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover tilt position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1021,13 +1091,9 @@ async def test_set_tilt_templated( ) -async def test_set_tilt_templated_and_attributes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover tilt position via template and using entities attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1049,10 +1115,14 @@ async def test_set_tilt_templated_and_attributes( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_tilt_templated_and_attributes( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover tilt position via template and using entities attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1111,13 +1181,9 @@ async def test_set_tilt_templated_and_attributes( ) -async def test_set_position_untemplated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1130,10 +1196,14 @@ async def test_set_position_untemplated( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_position_untemplated( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1145,13 +1215,9 @@ async def test_set_position_untemplated( mqtt_mock.async_publish.assert_called_once_with("position-topic", "62", 0, False) -async def test_set_position_untemplated_custom_percentage_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test setting cover position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1166,10 +1232,14 @@ async def test_set_position_untemplated_custom_percentage_range( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_position_untemplated_custom_percentage_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test setting cover position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1181,13 +1251,9 @@ async def test_set_position_untemplated_custom_percentage_range( mqtt_mock.async_publish.assert_called_once_with("position-topic", "62", 0, False) -async def test_no_command_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1200,21 +1266,21 @@ async def test_no_command_topic( "tilt_status_topic": "tilt-status", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_command_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no command topic.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 240 -async def test_no_payload_close( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no close payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1226,21 +1292,21 @@ async def test_no_payload_close( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_payload_close( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no close payload.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 9 -async def test_no_payload_open( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no open payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1252,21 +1318,21 @@ async def test_no_payload_open( "payload_stop": "STOP", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_payload_open( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no open payload.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 10 -async def test_no_payload_stop( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with no stop payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1278,21 +1344,21 @@ async def test_no_payload_stop( "payload_stop": None, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_payload_stop( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with no stop payload.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 3 -async def test_with_command_topic_and_tilt( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test with command topic and tilt config.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1306,21 +1372,21 @@ async def test_with_command_topic_and_tilt( "tilt_status_topic": "tilt-status", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_with_command_topic_and_tilt( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test with command topic and tilt config.""" + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 251 -async def test_tilt_defaults( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the defaults.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1335,23 +1401,23 @@ async def test_tilt_defaults( "tilt_status_topic": "tilt-status", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_defaults( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the defaults.""" + await mqtt_mock_entry_no_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes # Tilt position is not yet known assert ATTR_CURRENT_TILT_POSITION not in state_attributes_dict -async def test_tilt_via_invocation_defaults( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt defaults on close/open.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1366,10 +1432,15 @@ async def test_tilt_via_invocation_defaults( "tilt_status_topic": "tilt-status-topic", } } - }, - ) + } + ], +) +async def test_tilt_via_invocation_defaults( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt defaults on close/open.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1431,13 +1502,9 @@ async def test_tilt_via_invocation_defaults( mqtt_mock.async_publish.assert_called_once_with("tilt-command-topic", "0", 0, False) -async def test_tilt_given_value( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilting to a given value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1454,10 +1521,14 @@ async def test_tilt_given_value( "tilt_closed_value": 25, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_given_value( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilting to a given value.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1523,13 +1594,9 @@ async def test_tilt_given_value( ) -async def test_tilt_given_value_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilting to a given value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1547,10 +1614,14 @@ async def test_tilt_given_value_optimistic( "tilt_optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_given_value_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilting to a given value.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1603,13 +1674,9 @@ async def test_tilt_given_value_optimistic( ) -async def test_tilt_given_value_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilting to a given value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1629,10 +1696,14 @@ async def test_tilt_given_value_altered_range( "tilt_optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_given_value_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilting to a given value.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1683,13 +1754,9 @@ async def test_tilt_given_value_altered_range( ) -async def test_tilt_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt by updating status via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1704,10 +1771,14 @@ async def test_tilt_via_topic( "tilt_status_topic": "tilt-status-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt by updating status via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1724,13 +1795,9 @@ async def test_tilt_via_topic( assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt by updating status via MQTT and template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1748,10 +1815,14 @@ async def test_tilt_via_topic_template( "tilt_closed_value": 125, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt by updating status via MQTT and template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -1768,15 +1839,9 @@ async def test_tilt_via_topic_template( assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template_json_value( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test tilt by updating status via MQTT and template with JSON value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1794,10 +1859,16 @@ async def test_tilt_via_topic_template_json_value( "tilt_closed_value": 125, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_template_json_value( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test tilt by updating status via MQTT and template with JSON value.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", '{"Var1": 9, "Var2": 30}') @@ -1820,13 +1891,9 @@ async def test_tilt_via_topic_template_json_value( ) in caplog.text -async def test_tilt_via_topic_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt status via MQTT with altered tilt range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1843,10 +1910,14 @@ async def test_tilt_via_topic_altered_range( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt status via MQTT with altered tilt range.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1870,15 +1941,9 @@ async def test_tilt_via_topic_altered_range( assert current_cover_tilt_position == 50 -async def test_tilt_status_out_of_range_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test tilt status via MQTT tilt out of range warning message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1895,10 +1960,16 @@ async def test_tilt_status_out_of_range_warning( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_status_out_of_range_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test tilt status via MQTT tilt out of range warning message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "60") @@ -1907,15 +1978,9 @@ async def test_tilt_status_out_of_range_warning( ) in caplog.text -async def test_tilt_status_not_numeric_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test tilt status via MQTT tilt not numeric warning message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1932,23 +1997,25 @@ async def test_tilt_status_not_numeric_warning( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_status_not_numeric_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test tilt status via MQTT tilt not numeric warning message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "abc") assert ("Payload 'abc' is not numeric") in caplog.text -async def test_tilt_via_topic_altered_range_inverted( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt status via MQTT with altered tilt range and inverted tilt position.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -1965,10 +2032,14 @@ async def test_tilt_via_topic_altered_range_inverted( "tilt_max": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_altered_range_inverted( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt status via MQTT with altered tilt range and inverted tilt position.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1992,13 +2063,9 @@ async def test_tilt_via_topic_altered_range_inverted( assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt status via MQTT and template with altered tilt range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2018,10 +2085,14 @@ async def test_tilt_via_topic_template_altered_range( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_via_topic_template_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt status via MQTT and template with altered tilt range.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -2045,13 +2116,9 @@ async def test_tilt_via_topic_template_altered_range( assert current_cover_tilt_position == 50 -async def test_tilt_position( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt via method invocation.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2066,10 +2133,14 @@ async def test_tilt_position( "tilt_status_topic": "tilt-status-topic", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_position( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt via method invocation.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -2083,13 +2154,9 @@ async def test_tilt_position( ) -async def test_tilt_position_templated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt position via template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2105,10 +2172,14 @@ async def test_tilt_position_templated( "tilt_command_template": "{{100-32}}", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_position_templated( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt position via template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -2122,13 +2193,9 @@ async def test_tilt_position_templated( ) -async def test_tilt_position_altered_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test tilt via method invocation with altered range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2147,10 +2214,14 @@ async def test_tilt_position_altered_range( "tilt_max": 50, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_tilt_position_altered_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test tilt via method invocation with altered range.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -2164,6 +2235,7 @@ async def test_tilt_position_altered_range( ) +@pytest.mark.parametrize("hass_config", []) async def test_find_percentage_in_range_defaults(hass: HomeAssistant) -> None: """Test find percentage in range with default range.""" mqtt_cover = MqttCover( @@ -2546,13 +2618,9 @@ async def test_custom_availability_payload( ) -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of a valid device class.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2561,22 +2629,22 @@ async def test_valid_device_class( "state_topic": "test-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid device class.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.attributes.get("device_class") == "garage" -async def test_invalid_device_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test the setting of an invalid device class.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2585,8 +2653,17 @@ async def test_invalid_device_class( "state_topic": "test-topic", } } - }, - ) + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid device class.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [mqtt]: expected CoverDeviceClass" in caplog.text @@ -2821,13 +2898,9 @@ async def test_entity_debug_info_message( ) -async def test_state_and_position_topics_state_not_set_via_position_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state is not set via position topic when both state and position topics are set.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2842,10 +2915,14 @@ async def test_state_and_position_topics_state_not_set_via_position_topic( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_and_position_topics_state_not_set_via_position_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state is not set via position topic when both state and position topics are set.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -2882,13 +2959,9 @@ async def test_state_and_position_topics_state_not_set_via_position_topic( assert state.state == STATE_CLOSED -async def test_set_state_via_position_using_stopped_state( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via position topic using stopped state.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2904,10 +2977,14 @@ async def test_set_state_via_position_using_stopped_state( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_state_via_position_using_stopped_state( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via position topic using stopped state.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -2939,13 +3016,9 @@ async def test_set_state_via_position_using_stopped_state( assert state.state == STATE_OPEN -async def test_position_via_position_topic_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2957,10 +3030,14 @@ async def test_position_via_position_topic_template( "position_template": "{{ (value | multiply(0.01)) | int }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "99") @@ -2977,15 +3054,9 @@ async def test_position_via_position_topic_template( assert current_cover_position_position == 50 -async def test_position_via_position_topic_template_json_value( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test position by updating status via position template with a JSON value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -2997,10 +3068,16 @@ async def test_position_via_position_topic_template_json_value( "position_template": "{{ value_json.Var1 }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_json_value( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test position by updating status via position template with a JSON value.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", '{"Var1": 9, "Var2": 60}') @@ -3023,13 +3100,9 @@ async def test_position_via_position_topic_template_json_value( ) in caplog.text -async def test_position_template_with_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3046,10 +3119,14 @@ async def test_position_template_with_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_template_with_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "10") @@ -3066,13 +3143,9 @@ async def test_position_template_with_entity_id( assert current_cover_position_position == 20 -async def test_position_via_position_topic_template_return_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template and returning json.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3084,10 +3157,14 @@ async def test_position_via_position_topic_template_return_json( "position_template": '{{ {"position" : value} | tojson }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_return_json( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template and returning json.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -3097,15 +3174,9 @@ async def test_position_via_position_topic_template_return_json( assert current_cover_position_position == 55 -async def test_position_via_position_topic_template_return_json_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test position by updating status via position template returning json without position attribute.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3117,10 +3188,16 @@ async def test_position_via_position_topic_template_return_json_warning( "position_template": '{{ {"pos" : value} | tojson }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_return_json_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test position by updating status via position template returning json without position attribute.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -3130,13 +3207,9 @@ async def test_position_via_position_topic_template_return_json_warning( ) -async def test_position_and_tilt_via_position_topic_template_return_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position and tilt by updating the position via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3149,10 +3222,14 @@ async def test_position_and_tilt_via_position_topic_template_return_json( {{ {"position" : value, "tilt_position" : (value | int / 2)| int } | tojson }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_and_tilt_via_position_topic_template_return_json( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position and tilt by updating the position via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -3174,13 +3251,9 @@ async def test_position_and_tilt_via_position_topic_template_return_json( assert current_cover_position == 99 and current_tilt_position == 49 -async def test_position_via_position_topic_template_all_variables( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test position by updating status via position template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3203,10 +3276,14 @@ async def test_position_via_position_topic_template_all_variables( {% endif %}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_all_variables( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test position by updating status via position template.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -3222,13 +3299,9 @@ async def test_position_via_position_topic_template_all_variables( assert current_cover_position == 100 -async def test_set_state_via_stopped_state_no_position_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via stopped state when no position topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3244,10 +3317,14 @@ async def test_set_state_via_stopped_state_no_position_topic( "optimistic": False, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_state_via_stopped_state_no_position_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via stopped state when no position topic.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "state-topic", "OPEN") @@ -3275,15 +3352,9 @@ async def test_set_state_via_stopped_state_no_position_topic( assert state.state == STATE_CLOSED -async def test_position_via_position_topic_template_return_invalid_json( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test position by updating status via position template and returning invalid json.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3295,23 +3366,25 @@ async def test_position_via_position_topic_template_return_invalid_json( "position_template": '{{ {"position" : invalid_json} }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_position_via_position_topic_template_return_invalid_json( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test position by updating status via position template and returning invalid json.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") assert ("Payload '{'position': Undefined}' is not numeric") in caplog.text -async def test_set_position_topic_without_get_position_topic_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when set_position_topic is used without position_topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3321,21 +3394,25 @@ async def test_set_position_topic_without_get_position_topic_error( "value_template": "{{100-62}}", } } - }, - ) + } + ], +) +async def test_set_position_topic_without_get_position_topic_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when set_position_topic is used without position_topic.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_SET_POSITION_TOPIC}' must be set together with '{CONF_GET_POSITION_TOPIC}'." ) in caplog.text -async def test_value_template_without_state_topic_error( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error when value_template is used and state_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3344,20 +3421,25 @@ async def test_value_template_without_state_topic_error( "value_template": "{{100-62}}", } } - }, - ) + } + ], +) +async def test_value_template_without_state_topic_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when value_template is used and state_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_VALUE_TEMPLATE}' must be set together with '{CONF_STATE_TOPIC}'." ) in caplog.text -async def test_position_template_without_position_topic_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when position_template is used and position_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3366,22 +3448,26 @@ async def test_position_template_without_position_topic_error( "position_template": "{{100-52}}", } } - }, - ) + } + ], +) +async def test_position_template_without_position_topic_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when position_template is used and position_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_GET_POSITION_TEMPLATE}' must be set together with '{CONF_GET_POSITION_TOPIC}'." in caplog.text ) -async def test_set_position_template_without_set_position_topic( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error when set_position_template is used and set_position_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3390,21 +3476,26 @@ async def test_set_position_template_without_set_position_topic( "set_position_template": "{{100-42}}", } } - }, - ) + } + ], +) +async def test_set_position_template_without_set_position_topic( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when set_position_template is used and set_position_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_SET_POSITION_TEMPLATE}' must be set together with '{CONF_SET_POSITION_TOPIC}'." in caplog.text ) -async def test_tilt_command_template_without_tilt_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when tilt_command_template is used and tilt_command_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3413,21 +3504,26 @@ async def test_tilt_command_template_without_tilt_command_topic( "tilt_command_template": "{{100-32}}", } } - }, - ) + } + ], +) +async def test_tilt_command_template_without_tilt_command_topic( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when tilt_command_template is used and tilt_command_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_TILT_COMMAND_TEMPLATE}' must be set together with '{CONF_TILT_COMMAND_TOPIC}'." in caplog.text ) -async def test_tilt_status_template_without_tilt_status_topic_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test error when tilt_status_template is used and tilt_status_topic is missing.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { cover.DOMAIN: { @@ -3436,8 +3532,17 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( "tilt_status_template": "{{100-22}}", } } - }, - ) + } + ], +) +async def test_tilt_status_template_without_tilt_status_topic_topic( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when tilt_status_template is used and tilt_status_topic is missing.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_TILT_STATUS_TEMPLATE}' must be set together with '{CONF_TILT_STATUS_TOPIC}'." in caplog.text diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 41ff43aba878..9882b9102e14 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -32,7 +32,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -85,30 +84,24 @@ def fan_platform_only(): yield +@pytest.mark.parametrize("hass_config", [{mqtt.DOMAIN: {fan.DOMAIN: {"name": "test"}}}]) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test if command fails with command topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {fan.DOMAIN: {"name": "test"}}}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['fan'][0]['command_topic']" in caplog.text ) -async def test_controlling_state_via_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -139,10 +132,16 @@ async def test_controlling_state_via_topic( "payload_reset_preset_mode": "rEset_preset_mode", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -224,15 +223,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_with_different_speed_range( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic using an alternate speed range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: [ @@ -262,10 +255,16 @@ async def test_controlling_state_via_topic_with_different_speed_range( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_with_different_speed_range( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic using an alternate speed range.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "percentage-state-topic1", "100") state = hass.states.get("fan.test1") @@ -288,15 +287,9 @@ async def test_controlling_state_via_topic_with_different_speed_range( caplog.clear() -async def test_controlling_state_via_topic_no_percentage_topics( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic without percentage topics.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -314,10 +307,16 @@ async def test_controlling_state_via_topic_no_percentage_topics( ], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_no_percentage_topics( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic without percentage topics.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -347,15 +346,9 @@ async def test_controlling_state_via_topic_no_percentage_topics( caplog.clear() -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message (percentage mode).""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -384,10 +377,16 @@ async def test_controlling_state_via_topic_and_json_message( "speed_range_max": 100, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message (percentage mode).""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -454,15 +453,9 @@ async def test_controlling_state_via_topic_and_json_message( assert state.attributes.get("preset_mode") is None -async def test_controlling_state_via_topic_and_json_message_shared_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message using a shared topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -491,10 +484,16 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( "speed_range_max": 100, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message_shared_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message using a shared topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -544,15 +543,9 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( caplog.clear() -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -572,10 +565,15 @@ async def test_sending_mqtt_commands_and_optimistic( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -671,13 +669,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_with_alternate_speed_range( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic using an alternate speed range.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: [ @@ -707,10 +701,14 @@ async def test_sending_mqtt_commands_with_alternate_speed_range( }, ] } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_with_alternate_speed_range( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic using an alternate speed range.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_set_percentage(hass, "fan.test1", 0) mqtt_mock.async_publish.assert_called_once_with( @@ -777,15 +775,9 @@ async def test_sending_mqtt_commands_with_alternate_speed_range( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_optimistic_no_legacy( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic without legacy speed command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -800,10 +792,16 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic_no_legacy( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test optimistic mode without state topic without legacy speed command topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -911,15 +909,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy( await common.async_turn_on(hass, "fan.test", preset_mode="freaking-high") -async def test_sending_mqtt_command_templates_( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic without legacy speed command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -939,10 +931,15 @@ async def test_sending_mqtt_command_templates_( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_command_templates_( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode without state topic without legacy speed command topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1056,15 +1053,9 @@ async def test_sending_mqtt_command_templates_( await common.async_turn_on(hass, "fan.test", preset_mode="low") -async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic without percentage command topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -1080,10 +1071,15 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode without state topic without percentage command topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1120,15 +1116,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode with state topic and turn on attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -1149,10 +1139,15 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_explicit_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test optimistic mode with state topic and turn on attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1395,15 +1390,9 @@ async def test_encoding_subscribable_topics( ) -async def test_attributes( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { fan.DOMAIN: { @@ -1418,10 +1407,16 @@ async def test_attributes( ], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_attributes( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1452,13 +1447,17 @@ async def test_attributes( @pytest.mark.parametrize( - ("name", "config", "success", "features"), + ("name", "hass_config", "success", "features"), [ ( "test1", { - "name": "test1", - "command_topic": "command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test1", + "command_topic": "command-topic", + } + } }, True, 0, @@ -1466,29 +1465,41 @@ async def test_attributes( ( "test2", { - "name": "test2", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test2", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + } + } }, True, - fan.SUPPORT_OSCILLATE, + fan.FanEntityFeature.OSCILLATE, ), ( "test3", { - "name": "test3", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test3", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + } + } }, True, - fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.SET_SPEED, ), ( "test4", { - "name": "test4", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test4", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + } + } }, False, None, @@ -1496,100 +1507,136 @@ async def test_attributes( ( "test5", { - "name": "test5", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["eco", "auto"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test5", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["eco", "auto"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test6", { - "name": "test6", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["eco", "smart", "auto"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test6", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["eco", "smart", "auto"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test7", { - "name": "test7", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test7", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + } + } }, True, - fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.SET_SPEED, ), ( "test8", { - "name": "test8", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - "percentage_command_topic": "percentage-command-topic", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test8", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + "percentage_command_topic": "percentage-command-topic", + } + } }, True, - fan.SUPPORT_OSCILLATE | fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.OSCILLATE | fan.FanEntityFeature.SET_SPEED, ), ( "test9", { - "name": "test9", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["Mode1", "Mode2", "Mode3"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test9", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["Mode1", "Mode2", "Mode3"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test10", { - "name": "test10", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["whoosh", "silent", "auto"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test10", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["whoosh", "silent", "auto"], + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ( "test11", { - "name": "test11", - "command_topic": "command-topic", - "oscillation_command_topic": "oscillation-command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["Mode1", "Mode2", "Mode3"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test11", + "command_topic": "command-topic", + "oscillation_command_topic": "oscillation-command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["Mode1", "Mode2", "Mode3"], + } + } }, True, - fan.SUPPORT_PRESET_MODE | fan.SUPPORT_OSCILLATE, + fan.FanEntityFeature.PRESET_MODE | fan.FanEntityFeature.OSCILLATE, ), ( "test12", { - "name": "test12", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", - "speed_range_min": 1, - "speed_range_max": 40, + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test12", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 1, + "speed_range_max": 40, + } + } }, True, - fan.SUPPORT_SET_SPEED, + fan.FanEntityFeature.SET_SPEED, ), ( "test13", { - "name": "test13", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", - "speed_range_min": 50, - "speed_range_max": 40, + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test13", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 50, + "speed_range_max": 40, + } + } }, False, None, @@ -1597,11 +1644,15 @@ async def test_attributes( ( "test14", { - "name": "test14", - "command_topic": "command-topic", - "percentage_command_topic": "percentage-command-topic", - "speed_range_min": 0, - "speed_range_max": 40, + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test14", + "command_topic": "command-topic", + "percentage_command_topic": "percentage-command-topic", + "speed_range_min": 0, + "speed_range_max": 40, + } + } }, False, None, @@ -1609,10 +1660,14 @@ async def test_attributes( ( "test15", { - "name": "test7reset_payload_in_preset_modes_a", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["auto", "smart", "normal", "None"], + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test7reset_payload_in_preset_modes_a", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["auto", "smart", "normal", "None"], + } + } }, False, None, @@ -1620,39 +1675,37 @@ async def test_attributes( ( "test16", { - "name": "test16", - "command_topic": "command-topic", - "preset_mode_command_topic": "preset-mode-command-topic", - "preset_modes": ["whoosh", "silent", "auto", "None"], - "payload_reset_preset_mode": "normal", + mqtt.DOMAIN: { + fan.DOMAIN: { + "name": "test16", + "command_topic": "command-topic", + "preset_mode_command_topic": "preset-mode-command-topic", + "preset_modes": ["whoosh", "silent", "auto", "None"], + "payload_reset_preset_mode": "normal", + } + } }, True, - fan.SUPPORT_PRESET_MODE, + fan.FanEntityFeature.PRESET_MODE, ), ], ) async def test_supported_features( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - name, - config, - success, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + name: str, + success: bool, features, ) -> None: """Test optimistic mode without state topic.""" - - assert ( - await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {fan.DOMAIN: config}} - ) - is success - ) if success: - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(f"fan.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) From 2c1b59be0eac5b4264a1fb6321bd3359f71325a0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:14:44 +0100 Subject: [PATCH 0721/1058] Prepare MQTT platform tests part4 (#90107) * Tests humidifier * Tests legacy_vacuum --- tests/components/mqtt/test_humidifier.py | 422 +++++++++++--------- tests/components/mqtt/test_legacy_vacuum.py | 390 ++++++++---------- 2 files changed, 398 insertions(+), 414 deletions(-) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 89afe0a39720..0517e8e6a9c2 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -14,7 +14,6 @@ from homeassistant.components.humidifier import ( SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) -from homeassistant.components.mqtt import CONFIG_SCHEMA from homeassistant.components.mqtt.humidifier import ( CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, @@ -34,7 +33,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -130,30 +128,26 @@ async def async_set_humidity( await hass.services.async_call(DOMAIN, SERVICE_SET_HUMIDITY, data, blocking=True) +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {humidifier.DOMAIN: {"name": "test"}}}] +) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {humidifier.DOMAIN: {"name": "test"}}}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['humidifier'][0]['command_topic']. Got None" in caplog.text ) -async def test_controlling_state_via_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -178,10 +172,16 @@ async def test_controlling_state_via_topic( "payload_reset_mode": "rEset_mode", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -252,15 +252,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -281,10 +275,17 @@ async def test_controlling_state_via_topic_and_json_message( "mode_state_template": "{{ value_json.val }}", } } - }, - ) + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -343,15 +344,9 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_and_json_message_shared_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message using a shared topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -372,10 +367,16 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( "mode_state_template": "{{ value_json.mode }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message_shared_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message using a shared topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -422,15 +423,9 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( caplog.clear() -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -447,10 +442,16 @@ async def test_sending_mqtt_commands_and_optimistic( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -521,15 +522,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_command_templates_( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Testing command templates with optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -547,10 +542,16 @@ async def test_sending_mqtt_command_templates_( ], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_command_templates_( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Testing command templates with optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -621,15 +622,9 @@ async def test_sending_mqtt_command_templates_( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test optimistic mode with state topic and turn on attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -648,10 +643,16 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_explicit_optimistic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test optimistic mode with state topic and turn on attributes.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -774,15 +775,9 @@ async def test_encoding_subscribable_topics( ) -async def test_attributes( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test attributes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { humidifier.DOMAIN: { @@ -796,10 +791,16 @@ async def test_attributes( ], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_attributes( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -826,105 +827,142 @@ async def test_attributes( @pytest.mark.parametrize( - ("config", "valid"), + ("hass_config", "valid"), [ ( { - "name": "test_valid_1", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, True, ), ( { - "name": "test_valid_2", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "device_class": "humidifier", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "humidifier", + } + } }, True, ), ( { - "name": "test_valid_3", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "device_class": "dehumidifier", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_valid_3", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "dehumidifier", + } + } }, True, ), ( { - "name": "test_invalid_device_class", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "device_class": "notsupporedSpeci@l", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_device_class", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "notsupporedSpeci@l", + } + } }, False, ), ( { - "name": "test_mode_command_without_modes", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_mode_command_without_modes", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + } + } }, False, ), ( { - "name": "test_invalid_humidity_min_max_1", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "min_humidity": 0, - "max_humidity": 101, + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_humidity_min_max_1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "min_humidity": 0, + "max_humidity": 101, + } + } }, False, ), ( { - "name": "test_invalid_humidity_min_max_2", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "max_humidity": 20, - "min_humidity": 40, + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_humidity_min_max_2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "max_humidity": 20, + "min_humidity": 40, + } + } }, False, ), ( { - "name": "test_invalid_mode_is_reset", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", - "modes": ["eco", "None"], + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test_invalid_mode_is_reset", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "None"], + } + } }, False, ), ], ) -async def test_validity_configurations(hass: HomeAssistant, config, valid) -> None: +async def test_validity_configurations( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + valid: bool, +) -> None: """Test validity of configurations.""" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {humidifier.DOMAIN: config}}, - ) - is valid - ) + if valid: + await mqtt_mock_entry_no_yaml_config() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() @pytest.mark.parametrize( - ("name", "config", "success", "features"), + ("name", "hass_config", "success", "features"), [ ( "test1", { - "name": "test1", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, True, 0, @@ -932,21 +970,29 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ( "test2", { - "name": "test2", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", - "modes": ["eco", "auto"], + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "auto"], + } + } }, True, - humidifier.SUPPORT_MODES, + humidifier.HumidifierEntityFeature.MODES, ), ( "test3", { - "name": "test3", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test3", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, True, 0, @@ -954,20 +1000,28 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ( "test4", { - "name": "test4", - "command_topic": "command-topic", - "target_humidity_command_topic": "humidity-command-topic", - "mode_command_topic": "mode-command-topic", - "modes": ["eco", "auto"], + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test4", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "auto"], + } + } }, True, - humidifier.SUPPORT_MODES, + humidifier.HumidifierEntityFeature.MODES, ), ( "test5", { - "name": "test5", - "command_topic": "command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test5", + "command_topic": "command-topic", + } + } }, False, None, @@ -975,8 +1029,12 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ( "test6", { - "name": "test6", - "target_humidity_command_topic": "humidity-command-topic", + mqtt.DOMAIN: { + humidifier.DOMAIN: { + "name": "test6", + "target_humidity_command_topic": "humidity-command-topic", + } + } }, False, None, @@ -985,27 +1043,20 @@ async def test_validity_configurations(hass: HomeAssistant, config, valid) -> No ) async def test_supported_features( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - name, - config, - success, - features, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + name: str, + success: bool, + features: humidifier.HumidifierEntityFeature | None, ) -> None: """Test supported features.""" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {humidifier.DOMAIN: config}}, - ) - is success - ) if success: - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(f"humidifier.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -1388,17 +1439,6 @@ async def test_setup_manual_entity_from_yaml( assert hass.states.get(f"{platform}.test") -async def test_config_schema_validation(hass: HomeAssistant) -> None: - """Test invalid platform options in the config schema do not pass the config validation.""" - platform = humidifier.DOMAIN - config = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][platform]) - config["name"] = "test" - CONFIG_SCHEMA({mqtt.DOMAIN: {platform: config}}) - CONFIG_SCHEMA({mqtt.DOMAIN: {platform: [config]}}) - with pytest.raises(MultipleInvalid): - CONFIG_SCHEMA({mqtt.DOMAIN: {platform: [{"bla": "bla"}]}}) - - async def test_unload_config_entry( hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 42077fee0a70..e4cf5bb80302 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -32,9 +32,10 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +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, @@ -92,6 +93,28 @@ DEFAULT_CONFIG = { 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 + @pytest.fixture(autouse=True) def vacuum_platform_only(): @@ -100,13 +123,12 @@ def vacuum_platform_only(): yield +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the correct supported features.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() 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( @@ -122,20 +144,15 @@ async def test_default_supported_features( ) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test simple commands to the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_called_once_with( @@ -206,21 +223,27 @@ async def test_all_commands( } +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.STRING_TO_SERVICE["status"], SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test commands which are not supported by the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = mqttvacuum.STRING_TO_SERVICE["status"] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() @@ -259,21 +282,27 @@ async def test_commands_without_supported_features( 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test attributes which are not supported by the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = mqttvacuum.STRING_TO_SERVICE["turn_on"] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54, @@ -291,20 +320,15 @@ async def test_attributes_without_supported_features( 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54, @@ -336,20 +360,15 @@ async def test_status( 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54 @@ -359,20 +378,16 @@ async def test_status_battery( 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "cleaning": true @@ -382,20 +397,15 @@ async def test_status_cleaning( assert state.state == STATE_ON +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_docked( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "docked": true @@ -405,20 +415,15 @@ async def test_status_docked( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG_ALL_SERVICES], +) async def test_status_charging( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "charging": true @@ -428,20 +433,12 @@ async def test_status_charging( 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "fan_speed": "max" @@ -451,62 +448,52 @@ async def test_status_fan_speed( 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum. If the vacuum doesn't support fan speed, fan speed list should be None. """ - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = ALL_SERVICES - VacuumEntityFeature.FAN_SPEED - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "error": "Error1" @@ -523,26 +510,26 @@ async def test_status_error( 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that you can use non-default templates for battery_level.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.update( - { - mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ), - mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level", - mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}", - } - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "retroroomba/battery_level", "54") state = hass.states.get("vacuum.mqtttest") @@ -550,20 +537,12 @@ async def test_battery_template( 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_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") @@ -571,63 +550,28 @@ async def test_status_invalid_json( assert state.attributes.get(ATTR_STATUS) == "Stopped" -async def test_missing_battery_template(hass: HomeAssistant) -> None: +@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( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_charging_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_CHARGING_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_cleaning_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_CLEANING_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_docked_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_DOCKED_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_error_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_ERROR_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - - -async def test_missing_fan_speed_template(hass: HomeAssistant) -> None: - """Test to make sure missing template is not allowed.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config.pop(mqttvacuum.CONF_FAN_SPEED_TEMPLATE) - - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert ( + "Invalid config for [mqtt]: some but not all values in the same group of inclusion" + in caplog.text ) From db63c8584e22bd8b2afe3789d5204cc2dbd81da6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:16:54 +0100 Subject: [PATCH 0722/1058] Prepare MQTT platform tests part8 (#90132) * Tests state_vacuum * Tests siren --- tests/components/mqtt/test_siren.py | 279 +++++++++++---------- tests/components/mqtt/test_state_vacuum.py | 123 +++++---- 2 files changed, 214 insertions(+), 188 deletions(-) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 9837a3cc8a6a..7b20e802a5c9 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -19,9 +19,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -86,13 +86,9 @@ async def async_turn_off( await hass.services.async_call(siren.DOMAIN, SERVICE_TURN_OFF, data, blocking=True) -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -103,10 +99,14 @@ async def test_controlling_state_via_topic( "payload_off": 0, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -123,13 +123,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_OFF -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -140,10 +136,15 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": "2", } } - }, - ) + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_OFF @@ -167,15 +168,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_OFF -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -187,10 +182,16 @@ async def test_controlling_state_via_topic_and_json_message( "state_value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -210,15 +211,9 @@ async def test_controlling_state_via_topic_and_json_message( assert state.state == STATE_OFF -async def test_controlling_state_and_attributes_with_json_message_without_template( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the controlling state via topic and JSON message without a value template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -230,10 +225,16 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa "available_tones": ["ping", "siren", "bell"], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_and_attributes_with_json_message_without_template( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the controlling state via topic and JSON message without a value template.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -292,31 +293,41 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa assert state.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.6 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + { + mqtt.DOMAIN: { + siren.DOMAIN: { + "command_topic": "command-topic", + } + } + }, + ( + { + "name": "test1", + "available_tones": ["ping", "siren", "bell"], + "support_duration": False, + }, + { + "name": "test2", + "available_tones": ["ping", "siren", "bell"], + "support_volume_set": False, + }, + { + "name": "test3", + }, + ), + ) + ], +) async def test_filtering_not_supported_attributes_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting attributes with support flags optimistic.""" - config = { - "command_topic": "command-topic", - "available_tones": ["ping", "siren", "bell"], - } - config1 = copy.deepcopy(config) - config1["name"] = "test1" - config1["support_duration"] = False - config2 = copy.deepcopy(config) - config2["name"] = "test2" - config2["support_volume_set"] = False - config3 = copy.deepcopy(config) - config3["name"] = "test3" - del config3["available_tones"] - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {siren.DOMAIN: [config1, config2, config3]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state1 = hass.states.get("siren.test1") assert state1.state == STATE_OFF @@ -377,34 +388,44 @@ async def test_filtering_not_supported_attributes_optimistic( assert state3.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.88 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + { + mqtt.DOMAIN: { + siren.DOMAIN: { + "command_topic": "command-topic", + } + } + }, + ( + { + "name": "test1", + "state_topic": "state-topic1", + "available_tones": ["ping", "siren", "bell"], + "support_duration": False, + }, + { + "name": "test2", + "state_topic": "state-topic2", + "available_tones": ["ping", "siren", "bell"], + "support_volume_set": False, + }, + { + "name": "test3", + "state_topic": "state-topic3", + }, + ), + ) + ], +) async def test_filtering_not_supported_attributes_via_state( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting attributes with support flags via state.""" - config = { - "command_topic": "command-topic", - "available_tones": ["ping", "siren", "bell"], - } - config1 = copy.deepcopy(config) - config1["name"] = "test1" - config1["state_topic"] = "state-topic1" - config1["support_duration"] = False - config2 = copy.deepcopy(config) - config2["name"] = "test2" - config2["state_topic"] = "state-topic2" - config2["support_volume_set"] = False - config3 = copy.deepcopy(config) - config3["name"] = "test3" - config3["state_topic"] = "state-topic3" - del config3["available_tones"] - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {siren.DOMAIN: [config1, config2, config3]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state1 = hass.states.get("siren.test1") assert state1.state == STATE_UNKNOWN @@ -529,13 +550,9 @@ async def test_custom_availability_payload( ) -async def test_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the state payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { siren.DOMAIN: { @@ -548,10 +565,14 @@ async def test_custom_state_payload( "state_off": "LOW", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_custom_state_payload( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the state payload.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -762,30 +783,36 @@ async def test_discovery_update_siren_template( ) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + siren.DOMAIN, + DEFAULT_CONFIG, + ( + { + "name": "Beer", + "available_tones": ["ping", "chimes"], + "command_template": "CMD: {{ value }}, DURATION: {{ duration }}," + " TONE: {{ tone }}, VOLUME: {{ volume_level }}", + }, + { + "name": "Milk", + "available_tones": ["ping", "chimes"], + "command_template": "CMD: {{ value }}, DURATION: {{ duration }}," + " TONE: {{ tone }}, VOLUME: {{ volume_level }}", + "command_off_template": "CMD_OFF: {{ value }}", + }, + ), + ) + ], +) async def test_command_templates( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, ) -> None: """Test siren with command templates optimistic.""" - config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][siren.DOMAIN]) - config1["name"] = "Beer" - config1["available_tones"] = ["ping", "chimes"] - config1[ - "command_template" - ] = "CMD: {{ value }}, DURATION: {{ duration }}, TONE: {{ tone }}, VOLUME: {{ volume_level }}" - - config2 = copy.deepcopy(config1) - config2["name"] = "Milk" - config2["command_off_template"] = "CMD_OFF: {{ value }}" - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {siren.DOMAIN: [config1, config2]}}, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state1 = hass.states.get("siren.beer") assert state1.state == STATE_OFF diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index a5c838131008..dffaebca1724 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -29,9 +29,9 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -83,6 +83,18 @@ DEFAULT_CONFIG = { DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"schema": "state", "name": "test"}}} +CONFIG_ALL_SERVICES = help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING + ) + }, + ), +) + @pytest.fixture(autouse=True) def vacuum_platform_only(): @@ -91,13 +103,12 @@ def vacuum_platform_only(): yield +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the correct supported features.""" - assert await async_setup_component(hass, mqtt.DOMAIN, DEFAULT_CONFIG) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() 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( @@ -105,20 +116,12 @@ async def test_default_supported_features( ) +@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_all_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test simple commands send to the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -181,21 +184,27 @@ async def test_all_commands( } +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.STRING_TO_SERVICE["status"], SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_commands_without_supported_features( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test commands which are not supported by the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - services = mqttvacuum.STRING_TO_SERVICE["status"] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - services, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -243,20 +252,12 @@ async def test_commands_without_supported_features( mqtt_mock.async_publish.assert_not_called() +@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_status( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.state == STATE_UNKNOWN @@ -292,21 +293,27 @@ async def test_status( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ( + { + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING + ) + }, + ), + ) + ], +) async def test_no_fan_vacuum( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test status updates from the vacuum when fan is not supported.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - del config[mqttvacuum.CONF_FAN_SPEED_LIST] - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() message = """{ "battery_level": 54, @@ -347,21 +354,13 @@ async def test_no_fan_vacuum( assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 +@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) @pytest.mark.no_fail_on_log_exception async def test_status_invalid_json( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test to make sure nothing breaks if the vacuum sends bad JSON.""" - config = deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][vacuum.DOMAIN]) - config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( - mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING - ) - - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {vacuum.DOMAIN: config}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") From 185d6d74d75290cb331c2ebc98546c2ad17d9beb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 23 Mar 2023 19:17:27 +0100 Subject: [PATCH 0723/1058] Prepare MQTT platform tests part9 (#90133) * Tests switch * Tests text * Tests update --- tests/components/mqtt/test_switch.py | 117 +++++++------ tests/components/mqtt/test_text.py | 137 +++++++-------- tests/components/mqtt/test_update.py | 238 +++++++++++++-------------- 3 files changed, 249 insertions(+), 243 deletions(-) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 83580edf0039..79f2bcc4a7c8 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -15,7 +15,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -62,13 +61,9 @@ def switch_platform_only(): yield -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -80,10 +75,14 @@ async def test_controlling_state_via_topic( "device_class": "switch", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -106,16 +105,9 @@ async def test_controlling_state_via_topic( assert state.state == STATE_UNKNOWN -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands in optimistic mode.""" - fake_state = State("switch.test", "on") - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -126,10 +118,17 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": "2", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + fake_state = State("switch.test", "on") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_ON @@ -153,13 +152,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == STATE_OFF -async def test_sending_inital_state_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the initial state in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -167,23 +162,23 @@ async def test_sending_inital_state_and_optimistic( "command_topic": "command-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_inital_state_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the initial state in optimistic mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_controlling_state_via_topic_and_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -195,10 +190,14 @@ async def test_controlling_state_via_topic_and_json_message( "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic_and_json_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic and JSON message.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -292,13 +291,9 @@ async def test_custom_availability_payload( ) -async def test_custom_state_payload( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the state payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { switch.DOMAIN: { @@ -311,10 +306,14 @@ async def test_custom_state_payload( "state_off": "LOW", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_custom_state_payload( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the state payload.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index d12a03a9fa1a..10e9f0780d5b 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -14,7 +14,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -72,13 +71,9 @@ async def async_set_value( ) -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -88,10 +83,14 @@ async def test_controlling_state_via_topic( "mode": "password", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -114,15 +113,9 @@ async def test_controlling_state_via_topic( assert state.state == "" -async def test_controlling_validation_state_via_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the validation of a received state.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -135,10 +128,16 @@ async def test_controlling_validation_state_via_topic( "pattern": "(y|n)", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_validation_state_via_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the validation of a received state.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -188,11 +187,9 @@ async def test_controlling_validation_state_via_topic( assert state.state == "no" -async def test_attribute_validation_max_greater_then_min(hass: HomeAssistant) -> None: - """Test the validation of min and max configuration attributes.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -202,17 +199,20 @@ async def test_attribute_validation_max_greater_then_min(hass: HomeAssistant) -> "max": 10, } } - }, - ) - - -async def test_attribute_validation_max_not_greater_then_max_state_length( - hass: HomeAssistant, + } + ], +) +async def test_attribute_validation_max_greater_then_min( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test the max value of of max configuration attribute.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, + """Test the validation of min and max configuration attributes.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -222,17 +222,20 @@ async def test_attribute_validation_max_not_greater_then_max_state_length( "max": 257, } } - }, - ) - - -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_attribute_validation_max_not_greater_then_max_state_length( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: - """Test the sending MQTT commands in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test the max value of of max configuration attribute.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -241,10 +244,14 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": "2", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN @@ -269,13 +276,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.state == "some new state" -async def test_set_text_validation( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the initial state in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { text.DOMAIN: { @@ -287,10 +290,14 @@ async def test_set_text_validation( "pattern": "(y|n)", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_set_text_validation( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the initial state in optimistic mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("text.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 200a3ca6dd86..bdd85768b855 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -14,7 +14,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -63,20 +62,14 @@ def update_platform_only(): yield -async def test_run_update_setup( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, - "latest_version_topic": latest_version_topic, + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", "name": "Test Update", "release_summary": "Test release summary", "release_url": "https://example.com/release", @@ -84,10 +77,16 @@ async def test_run_update_setup( "entity_picture": "https://example.com/icon.png", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_update_setup( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") async_fire_mqtt_message(hass, latest_version_topic, "1.9.0") @@ -113,20 +112,14 @@ async def test_run_update_setup( assert state.attributes.get("latest_version") == "2.0.0" -async def test_run_update_setup_float( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload when the version is parsable as a number.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, - "latest_version_topic": latest_version_topic, + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", "name": "Test Update", "release_summary": "Test release summary", "release_url": "https://example.com/release", @@ -134,10 +127,16 @@ async def test_run_update_setup_float( "entity_picture": "https://example.com/icon.png", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_update_setup_float( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload when the version is parsable as a number.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, "1.9") async_fire_mqtt_message(hass, latest_version_topic, "1.9") @@ -163,29 +162,29 @@ async def test_run_update_setup_float( assert state.attributes.get("latest_version") == "2.0" -async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, + "state_topic": "test/installed-version", "value_template": "{{ value_json.installed }}", - "latest_version_topic": latest_version_topic, + "latest_version_topic": "test/latest-version", "latest_version_template": "{{ value_json.latest }}", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9.0"}') async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9.0"}') @@ -211,29 +210,29 @@ async def test_value_template( assert state.attributes.get("latest_version") == "2.0.0" -async def test_value_template_float( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template when the version is parsable as a number.""" - installed_version_topic = "test/installed-version" - latest_version_topic = "test/latest-version" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": installed_version_topic, + "state_topic": "test/installed-version", "value_template": "{{ value_json.installed }}", - "latest_version_topic": latest_version_topic, + "latest_version_topic": "test/latest-version", "latest_version_template": "{{ value_json.latest }}", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template_float( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template when the version is parsable as a number.""" + installed_version_topic = "test/installed-version" + latest_version_topic = "test/latest-version" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, '{"installed":"1.9"}') async_fire_mqtt_message(hass, latest_version_topic, '{"latest":"1.9"}') @@ -259,25 +258,25 @@ async def test_value_template_float( assert state.attributes.get("latest_version") == "2.0" -async def test_empty_json_state_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test an empty JSON payload.""" - state_topic = "test/state-topic" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": state_topic, + "state_topic": "test/state-topic", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_empty_json_state_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test an empty JSON payload.""" + state_topic = "test/state-topic" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "{}") @@ -287,25 +286,25 @@ async def test_empty_json_state_message( assert state.state == STATE_UNKNOWN -async def test_json_state_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test whether it fetches data from a JSON payload.""" - state_topic = "test/state-topic" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": state_topic, + "state_topic": "test/state-topic", "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_json_state_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test whether it fetches data from a JSON payload.""" + state_topic = "test/state-topic" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, @@ -343,26 +342,27 @@ async def test_json_state_message( assert state.attributes.get("entity_picture") == "https://example.com/icon2.png" -async def test_json_state_message_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test whether it fetches data from a JSON payload with template.""" - state_topic = "test/state-topic" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { update.DOMAIN: { - "state_topic": state_topic, - "value_template": '{{ {"installed_version": value_json.installed, "latest_version": value_json.latest} | to_json }}', + "state_topic": "test/state-topic", + "value_template": '{{ {"installed_version": value_json.installed, ' + '"latest_version": value_json.latest} | to_json }}', "name": "Test Update", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_json_state_message_with_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test whether it fetches data from a JSON payload with template.""" + state_topic = "test/state-topic" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, '{"installed":"1.9.0","latest":"1.9.0"}') @@ -383,31 +383,31 @@ async def test_json_state_message_with_template( assert state.attributes.get("latest_version") == "2.0.0" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + update.DOMAIN: { + "state_topic": "test/installed-version", + "latest_version_topic": "test/latest-version", + "command_topic": "test/install-command", + "payload_install": "install", + "name": "Test Update", + } + } + } + ], +) async def test_run_install_service( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that install service works.""" installed_version_topic = "test/installed-version" latest_version_topic = "test/latest-version" command_topic = "test/install-command" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - update.DOMAIN: { - "state_topic": installed_version_topic, - "latest_version_topic": latest_version_topic, - "command_topic": command_topic, - "payload_install": "install", - "name": "Test Update", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, installed_version_topic, "1.9.0") async_fire_mqtt_message(hass, latest_version_topic, "2.0.0") From 3e3ece4e56d26b74445dfc235102c68e57ac15c3 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 23 Mar 2023 13:44:19 -0500 Subject: [PATCH 0724/1058] Add speech to text over binary websocket to pipeline (#90082) * Allow passing binary to the WS connection * Expand test coverage * Test non-existing handler * Add text to speech and stages to pipeline * Default to "cloud" TTS when engine is None * Refactor pipeline request to split text/audio * Refactor with PipelineRun * Generate pipeline from language * Clean up * Restore TTS code * Add audio pipeline test * Clean TTS cache in test * Clean up tests and pipeline base class * Stop pylint and pytest magics from fighting * Include mock_get_cache_files * Working on STT * Preparing to test * First successful test * Send handler_id * Allow signaling end of stream using empty payloads * Store handlers in a list * Handle binary handlers raising exceptions * Add stt/tts dependencies to voice_assistant * Include STT audio in pipeline test * Working on tests * Refactoring with stages * Fix tests * Add more tests * Add method docs * Change stt demo/cloud to AsyncIterable * Add pipeline error events * Move handler id to separate message before pipeline * Add test for invalid stage order * Change "finish" to "end" * Use enum --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/cloud/stt.py | 5 +- homeassistant/components/demo/stt.py | 6 +- homeassistant/components/stt/__init__.py | 5 +- .../components/voice_assistant/manifest.json | 2 +- .../components/voice_assistant/pipeline.py | 356 ++++++++++--- .../voice_assistant/websocket_api.py | 127 +++-- tests/components/stt/test_init.py | 4 +- .../voice_assistant/test_pipeline.py | 110 ---- .../voice_assistant/test_websocket.py | 479 +++++++++++++++++- 9 files changed, 860 insertions(+), 234 deletions(-) delete mode 100644 tests/components/voice_assistant/test_pipeline.py diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 70618ab38ef0..bdce055c3c44 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -1,7 +1,8 @@ """Support for the cloud for speech to text service.""" from __future__ import annotations -from aiohttp import StreamReader +from collections.abc import AsyncIterable + from hass_nabucasa import Cloud from hass_nabucasa.voice import VoiceError @@ -88,7 +89,7 @@ class CloudProvider(Provider): return [AudioChannels.CHANNEL_MONO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" content = ( diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 9c3cf89d80ea..923092fad20a 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -1,7 +1,7 @@ """Support for the demo for speech to text service.""" from __future__ import annotations -from aiohttp import StreamReader +from collections.abc import AsyncIterable from homeassistant.components.stt import ( AudioBitRates, @@ -63,12 +63,12 @@ class DemoProvider(Provider): return [AudioChannels.CHANNEL_STEREO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service.""" # Read available data - async for _ in stream.iter_chunked(4096): + async for _ in stream: pass return SpeechResult("Turn the Kitchen Lights on", SpeechResultState.SUCCESS) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 94e08d253635..631994021942 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -3,11 +3,12 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio +from collections.abc import AsyncIterable from dataclasses import asdict, dataclass import logging from typing import Any -from aiohttp import StreamReader, web +from aiohttp import web from aiohttp.hdrs import istr from aiohttp.web_exceptions import ( HTTPBadRequest, @@ -153,7 +154,7 @@ class Provider(ABC): @abstractmethod async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream to STT service. diff --git a/homeassistant/components/voice_assistant/manifest.json b/homeassistant/components/voice_assistant/manifest.json index 6d353660b31d..644c49e94597 100644 --- a/homeassistant/components/voice_assistant/manifest.json +++ b/homeassistant/components/voice_assistant/manifest.json @@ -2,7 +2,7 @@ "domain": "voice_assistant", "name": "Voice Assistant", "codeowners": ["@balloob", "@synesthesiam"], - "dependencies": ["conversation"], + "dependencies": ["conversation", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/voice_assistant", "iot_class": "local_push", "quality_scale": "internal" diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 0b55d724554a..0070154bd40c 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -1,33 +1,80 @@ """Classes for voice assistant pipelines.""" from __future__ import annotations -from abc import ABC, abstractmethod import asyncio -from collections.abc import Callable -from dataclasses import dataclass, field +from collections.abc import AsyncIterable, Callable +from dataclasses import asdict, dataclass, field +import logging from typing import Any from homeassistant.backports.enum import StrEnum -from homeassistant.components import conversation -from homeassistant.components.media_source import async_resolve_media +from homeassistant.components import conversation, media_source, stt from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, ) -from homeassistant.core import Context, HomeAssistant +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.util.dt import utcnow +from .const import DOMAIN + DEFAULT_TIMEOUT = 30 # seconds +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_get_pipeline( + hass: HomeAssistant, pipeline_id: str | None = None, language: str | None = None +) -> Pipeline | None: + """Get a pipeline by id or create one for a language.""" + if pipeline_id is not None: + return hass.data[DOMAIN].get(pipeline_id) + + # Construct a pipeline for the required/configured language + language = language or hass.config.language + return Pipeline( + name=language, + language=language, + stt_engine=None, # first engine + conversation_engine=None, # first agent + tts_engine=None, # first engine + ) + + +class PipelineError(Exception): + """Base class for pipeline errors.""" + + def __init__(self, code: str, message: str) -> None: + """Set error message.""" + self.code = code + self.message = message + + super().__init__(f"Pipeline error code={code}, message={message}") + + +class SpeechToTextError(PipelineError): + """Error in speech to text portion of pipeline.""" + + +class IntentRecognitionError(PipelineError): + """Error in intent recognition portion of pipeline.""" + + +class TextToSpeechError(PipelineError): + """Error in text to speech portion of pipeline.""" + class PipelineEventType(StrEnum): """Event types emitted during a pipeline run.""" RUN_START = "run-start" - RUN_FINISH = "run-finish" + RUN_END = "run-end" + STT_START = "stt-start" + STT_END = "stt-end" INTENT_START = "intent-start" - INTENT_FINISH = "intent-finish" + INTENT_END = "intent-end" TTS_START = "tts-start" - TTS_FINISH = "tts-finish" + TTS_END = "tts-end" ERROR = "error" @@ -54,10 +101,44 @@ class Pipeline: name: str language: str | None + stt_engine: str | None conversation_engine: str | None tts_engine: str | None +class PipelineStage(StrEnum): + """Stages of a pipeline.""" + + STT = "stt" + INTENT = "intent" + TTS = "tts" + + +PIPELINE_STAGE_ORDER = [ + PipelineStage.STT, + PipelineStage.INTENT, + PipelineStage.TTS, +] + + +class PipelineRunValidationError(Exception): + """Error when a pipeline run is not valid.""" + + +class InvalidPipelineStagesError(PipelineRunValidationError): + """Error when given an invalid combination of start/end stages.""" + + def __init__( + self, + start_stage: PipelineStage, + end_stage: PipelineStage, + ) -> None: + """Set error message.""" + super().__init__( + f"Invalid stage combination: start={start_stage}, end={end_stage}" + ) + + @dataclass class PipelineRun: """Running context for a pipeline.""" @@ -65,6 +146,8 @@ class PipelineRun: hass: HomeAssistant context: Context pipeline: Pipeline + start_stage: PipelineStage + end_stage: PipelineStage event_callback: Callable[[PipelineEvent], None] language: str = None # type: ignore[assignment] @@ -72,6 +155,12 @@ class PipelineRun: """Set language for pipeline.""" self.language = self.pipeline.language or self.hass.config.language + # stt -> intent -> tts + if PIPELINE_STAGE_ORDER.index(self.end_stage) < PIPELINE_STAGE_ORDER.index( + self.start_stage + ): + raise InvalidPipelineStagesError(self.start_stage, self.end_stage) + def start(self): """Emit run start event.""" self.event_callback( @@ -84,18 +173,86 @@ class PipelineRun: ) ) - def finish(self): - """Emit run finish event.""" + def end(self): + """Emit run end event.""" self.event_callback( PipelineEvent( - PipelineEventType.RUN_FINISH, + PipelineEventType.RUN_END, ) ) + async def speech_to_text( + self, + metadata: stt.SpeechMetadata, + stream: AsyncIterable[bytes], + ) -> str: + """Run speech to text portion of pipeline. Returns the spoken text.""" + engine = self.pipeline.stt_engine or "default" + self.event_callback( + PipelineEvent( + PipelineEventType.STT_START, + { + "engine": engine, + "metadata": asdict(metadata), + }, + ) + ) + + try: + # Load provider + stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + assert stt_provider is not None + except Exception as src_error: + stt_error = SpeechToTextError( + code="stt-provider-missing", + message=f"No speech to text provider for: {engine}", + ) + _LOGGER.exception(stt_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": stt_error.code, "message": stt_error.message}, + ) + ) + raise stt_error from src_error + + try: + # Transcribe audio stream + result = await stt_provider.async_process_audio_stream(metadata, stream) + assert (result.text is not None) and ( + result.result == stt.SpeechResultState.SUCCESS + ) + except Exception as src_error: + stt_error = SpeechToTextError( + code="stt-stream-failed", + message="Unexpected error during speech to text", + ) + _LOGGER.exception(stt_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": stt_error.code, "message": stt_error.message}, + ) + ) + raise stt_error from src_error + + self.event_callback( + PipelineEvent( + PipelineEventType.STT_END, + { + "stt_output": { + "text": result.text, + } + }, + ) + ) + + return result.text + async def recognize_intent( self, intent_input: str, conversation_id: str | None - ) -> conversation.ConversationResult: - """Run intent recognition portion of pipeline.""" + ) -> str: + """Run intent recognition portion of pipeline. Returns text to speak.""" self.event_callback( PipelineEvent( PipelineEventType.INTENT_START, @@ -106,23 +263,39 @@ class PipelineRun: ) ) - conversation_result = await conversation.async_converse( - hass=self.hass, - text=intent_input, - conversation_id=conversation_id, - context=self.context, - language=self.language, - agent_id=self.pipeline.conversation_engine, - ) + try: + conversation_result = await conversation.async_converse( + hass=self.hass, + text=intent_input, + conversation_id=conversation_id, + context=self.context, + language=self.language, + agent_id=self.pipeline.conversation_engine, + ) + except Exception as src_error: + intent_error = IntentRecognitionError( + code="intent-failed", + message="Unexpected error during intent recognition", + ) + _LOGGER.exception(intent_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": intent_error.code, "message": intent_error.message}, + ) + ) + raise intent_error from src_error self.event_callback( PipelineEvent( - PipelineEventType.INTENT_FINISH, + PipelineEventType.INTENT_END, {"intent_output": conversation_result.as_dict()}, ) ) - return conversation_result + speech = conversation_result.response.speech.get("plain", {}).get("speech", "") + + return speech async def text_to_speech(self, tts_input: str) -> str: """Run text to speech portion of pipeline. Returns URL of TTS audio.""" @@ -136,29 +309,57 @@ class PipelineRun: ) ) - tts_media = await async_resolve_media( - self.hass, - tts_generate_media_source_id( + try: + # Synthesize audio and get URL + tts_media = await media_source.async_resolve_media( self.hass, - tts_input, - engine=self.pipeline.tts_engine, - ), - ) - tts_url = tts_media.url + tts_generate_media_source_id( + self.hass, + tts_input, + engine=self.pipeline.tts_engine, + ), + ) + except Exception as src_error: + tts_error = TextToSpeechError( + code="tts-failed", + message="Unexpected error during text to speech", + ) + _LOGGER.exception(tts_error.message) + self.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": tts_error.code, "message": tts_error.message}, + ) + ) + raise tts_error from src_error self.event_callback( PipelineEvent( - PipelineEventType.TTS_FINISH, - {"tts_output": tts_url}, + PipelineEventType.TTS_END, + {"tts_output": asdict(tts_media)}, ) ) - return tts_url + return tts_media.url @dataclass -class PipelineRequest(ABC): - """Request to for a pipeline run.""" +class PipelineInput: + """Input to a pipeline run.""" + + stt_metadata: stt.SpeechMetadata | None = None + """Metadata of stt input audio. Required when start_stage = stt.""" + + stt_stream: AsyncIterable[bytes] | None = None + """Input audio for stt. Required when start_stage = stt.""" + + intent_input: str | None = None + """Input for conversation agent. Required when start_stage = intent.""" + + tts_input: str | None = None + """Input for text to speech. Required when start_stage = tts.""" + + conversation_id: str | None = None async def execute( self, run: PipelineRun, timeout: int | float | None = DEFAULT_TIMEOUT @@ -169,47 +370,60 @@ class PipelineRequest(ABC): timeout=timeout, ) - @abstractmethod async def _execute(self, run: PipelineRun): - """Run pipeline with request info and context.""" + self._validate(run.start_stage) - -@dataclass -class TextPipelineRequest(PipelineRequest): - """Request to run the text portion only of a pipeline.""" - - intent_input: str - conversation_id: str | None = None - - async def _execute( - self, - run: PipelineRun, - ): + # stt -> intent -> tts run.start() - await run.recognize_intent(self.intent_input, self.conversation_id) - run.finish() + current_stage = run.start_stage + # Speech to text + intent_input = self.intent_input + if current_stage == PipelineStage.STT: + assert self.stt_metadata is not None + assert self.stt_stream is not None + intent_input = await run.speech_to_text( + self.stt_metadata, + self.stt_stream, + ) + current_stage = PipelineStage.INTENT -@dataclass -class AudioPipelineRequest(PipelineRequest): - """Request to full pipeline from audio input (stt) to audio output (tts).""" + if run.end_stage != PipelineStage.STT: + tts_input = self.tts_input - intent_input: str # this will be changed to stt audio - conversation_id: str | None = None + if current_stage == PipelineStage.INTENT: + assert intent_input is not None + tts_input = await run.recognize_intent( + intent_input, self.conversation_id + ) + current_stage = PipelineStage.TTS - async def _execute(self, run: PipelineRun): - run.start() + if run.end_stage != PipelineStage.INTENT: + if current_stage == PipelineStage.TTS: + assert tts_input is not None + await run.text_to_speech(tts_input) - # stt will go here + run.end() - conversation_result = await run.recognize_intent( - self.intent_input, self.conversation_id - ) + def _validate(self, stage: PipelineStage): + """Validate pipeline input against start stage.""" + if stage == PipelineStage.STT: + if self.stt_metadata is None: + raise PipelineRunValidationError( + "stt_metadata is required for speech to text" + ) - tts_input = conversation_result.response.speech.get("plain", {}).get( - "speech", "" - ) - - await run.text_to_speech(tts_input) - - run.finish() + if self.stt_stream is None: + raise PipelineRunValidationError( + "stt_stream is required for speech to text" + ) + elif stage == PipelineStage.INTENT: + if self.intent_input is None: + raise PipelineRunValidationError( + "intent_input is required for intent recognition" + ) + elif stage == PipelineStage.TTS: + if self.tts_input is None: + raise PipelineRunValidationError( + "tts_input is required for text to speech" + ) diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index 54e87e292a17..cc4799f13e78 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -1,13 +1,24 @@ """Voice Assistant Websocket API.""" +import asyncio +from collections.abc import Callable +import logging from typing import Any import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import stt, websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN -from .pipeline import DEFAULT_TIMEOUT, Pipeline, PipelineRun, TextPipelineRequest +from .pipeline import ( + DEFAULT_TIMEOUT, + PipelineError, + PipelineInput, + PipelineRun, + PipelineStage, + async_get_pipeline, +) + +_LOGGER = logging.getLogger(__name__) @callback @@ -19,9 +30,13 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", + # pylint: disable-next=unnecessary-lambda + vol.Required("start_stage"): lambda val: PipelineStage(val), + # pylint: disable-next=unnecessary-lambda + vol.Required("end_stage"): lambda val: PipelineStage(val), + vol.Optional("input"): {"text": str}, vol.Optional("language"): str, vol.Optional("pipeline"): str, - vol.Required("intent_input"): str, vol.Optional("conversation_id"): vol.Any(str, None), vol.Optional("timeout"): vol.Any(float, int), } @@ -33,39 +48,74 @@ async def websocket_run( msg: dict[str, Any], ) -> None: """Run a pipeline.""" + language = msg.get("language", hass.config.language) pipeline_id = msg.get("pipeline") - if pipeline_id is not None: - pipeline = hass.data[DOMAIN].get(pipeline_id) - if pipeline is None: - connection.send_error( - msg["id"], - "pipeline_not_found", - f"Pipeline not found: {pipeline_id}", - ) - return + pipeline = async_get_pipeline( + hass, + pipeline_id=pipeline_id, + language=language, + ) + if pipeline is None: + connection.send_error( + msg["id"], + "pipeline-not-found", + f"Pipeline not found: id={pipeline_id}, language={language}", + ) + return - else: - # Construct a pipeline for the required/configured language - language = msg.get("language", hass.config.language) - pipeline = Pipeline( - name=language, - language=language, - conversation_engine=None, - tts_engine=None, + timeout = msg.get("timeout", DEFAULT_TIMEOUT) + start_stage = PipelineStage(msg["start_stage"]) + end_stage = PipelineStage(msg["end_stage"]) + handler_id: int | None = None + unregister_handler: Callable[[], None] | None = None + + # Arguments to PipelineInput + input_args: dict[str, Any] = { + "conversation_id": msg.get("conversation_id"), + } + + if start_stage == PipelineStage.STT: + # Audio pipeline that will receive audio as binary websocket messages + audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() + + async def stt_stream(): + # Yield until we receive an empty chunk + while chunk := await audio_queue.get(): + yield chunk + + def handle_binary(_hass, _connection, data: bytes): + # Forward to STT audio stream + audio_queue.put_nowait(data) + + handler_id, unregister_handler = connection.async_register_binary_handler( + handle_binary ) - # Run pipeline with a timeout. - # Events are sent over the websocket connection. - timeout = msg.get("timeout", DEFAULT_TIMEOUT) + # Audio input must be raw PCM at 16Khz with 16-bit mono samples + input_args["stt_metadata"] = stt.SpeechMetadata( + language=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, + ) + input_args["stt_stream"] = stt_stream() + elif start_stage == PipelineStage.INTENT: + # Input to conversation agent + input_args["intent_input"] = msg["input"]["text"] + elif start_stage == PipelineStage.TTS: + # Input to text to speech system + input_args["tts_input"] = msg["input"]["text"] + run_task = hass.async_create_task( - TextPipelineRequest( - intent_input=msg["intent_input"], - conversation_id=msg.get("conversation_id"), - ).execute( + PipelineInput(**input_args).execute( PipelineRun( hass, - connection.context(msg), - pipeline, + context=connection.context(msg), + pipeline=pipeline, + start_stage=start_stage, + end_stage=end_stage, event_callback=lambda event: connection.send_event( msg["id"], event.as_dict() ), @@ -77,7 +127,20 @@ async def websocket_run( # Cancel pipeline if user unsubscribes connection.subscriptions[msg["id"]] = run_task.cancel + # Confirm subscription connection.send_result(msg["id"]) - # Task contains a timeout - await run_task + if handler_id is not None: + # Send handler id to client + connection.send_event(msg["id"], {"handler_id": handler_id}) + + try: + # Task contains a timeout + await run_task + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + finally: + if unregister_handler is not None: + # Unregister binary handler + unregister_handler() diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index e36b8af3f6cf..3d20dbc54034 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -1,5 +1,5 @@ """Test STT component setup.""" -from asyncio import StreamReader +from collections.abc import AsyncIterable from http import HTTPStatus from unittest.mock import AsyncMock, Mock @@ -64,7 +64,7 @@ class MockProvider(Provider): return [AudioChannels.CHANNEL_MONO] async def async_process_audio_stream( - self, metadata: SpeechMetadata, stream: StreamReader + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: """Process an audio stream.""" self.calls.append((metadata, stream)) diff --git a/tests/components/voice_assistant/test_pipeline.py b/tests/components/voice_assistant/test_pipeline.py deleted file mode 100644 index 343719a49fd2..000000000000 --- a/tests/components/voice_assistant/test_pipeline.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Pipeline tests for Voice Assistant integration.""" -from unittest.mock import MagicMock, patch - -import pytest - -from homeassistant.components.voice_assistant.pipeline import ( - AudioPipelineRequest, - Pipeline, - PipelineEventType, - PipelineRun, -) -from homeassistant.core import Context -from homeassistant.setup import async_setup_component - -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - mock_get_cache_files, - mock_init_cache_dir, -) - - -@pytest.fixture(autouse=True) -async def init_components(hass): - """Initialize relevant components with empty configs.""" - assert await async_setup_component(hass, "voice_assistant", {}) - - -@pytest.fixture -async def mock_get_tts_audio(hass): - """Set up media source.""" - assert await async_setup_component(hass, "media_source", {}) - assert await async_setup_component( - hass, - "tts", - { - "tts": { - "platform": "demo", - } - }, - ) - - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=("mp3", b""), - ) as mock_get_tts: - yield mock_get_tts - - -async def test_audio_pipeline(hass, mock_get_tts_audio): - """Run audio pipeline with mock TTS.""" - pipeline = Pipeline( - name="test", - language=hass.config.language, - conversation_engine=None, - tts_engine=None, - ) - - event_callback = MagicMock() - await AudioPipelineRequest(intent_input="Are the lights on?").execute( - PipelineRun( - hass, - context=Context(), - pipeline=pipeline, - event_callback=event_callback, - language=hass.config.language, - ) - ) - - calls = event_callback.mock_calls - assert calls[0].args[0].type == PipelineEventType.RUN_START - assert calls[0].args[0].data == { - "pipeline": "test", - "language": hass.config.language, - } - - assert calls[1].args[0].type == PipelineEventType.INTENT_START - assert calls[1].args[0].data == { - "engine": "default", - "intent_input": "Are the lights on?", - } - assert calls[2].args[0].type == PipelineEventType.INTENT_FINISH - assert calls[2].args[0].data == { - "intent_output": { - "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 calls[3].args[0].type == PipelineEventType.TTS_START - assert calls[3].args[0].data == { - "engine": "default", - "tts_input": "Sorry, I couldn't understand that", - } - assert calls[4].args[0].type == PipelineEventType.TTS_FINISH - assert ( - calls[4].args[0].data["tts_output"] - == f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3" - ) - - assert calls[5].args[0].type == PipelineEventType.RUN_FINISH diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index 2fec6cdfb03b..a1ba8b5f7cbb 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -1,20 +1,94 @@ """Websocket tests for Voice Assistant integration.""" import asyncio -from unittest.mock import patch +from collections.abc import AsyncIterable +from unittest.mock import MagicMock, patch import pytest +from homeassistant.components import stt from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import + mock_get_cache_files, + mock_init_cache_dir, +) from tests.typing import WebSocketGenerator +_TRANSCRIPT = "test transcript" + + +class MockSttProvider(stt.Provider): + """Mock STT provider.""" + + def __init__(self, hass: HomeAssistant, text: str) -> None: + """Init test provider.""" + self.hass = hass + self.text = text + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return [self.hass.config.language] + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + return [stt.AudioFormats.WAV] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bitrates.""" + return [stt.AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported samplerates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream.""" + return stt.SpeechResult(self.text, stt.SpeechResultState.SUCCESS) + @pytest.fixture(autouse=True) async def init_components(hass): """Initialize relevant components with empty configs.""" + assert await async_setup_component(hass, "media_source", {}) + assert await async_setup_component( + hass, + "tts", + { + "tts": { + "platform": "demo", + } + }, + ) + assert await async_setup_component(hass, "stt", {}) + + # mock_platform fails because it can't import + hass.data[stt.DOMAIN] = {"test": MockSttProvider(hass, _TRANSCRIPT)} + assert await async_setup_component(hass, "voice_assistant", {}) + with patch( + "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", + return_value=("mp3", b""), + ) as mock_get_tts: + yield mock_get_tts + async def test_text_only_pipeline( hass: HomeAssistant, @@ -27,7 +101,9 @@ async def test_text_only_pipeline( { "id": 5, "type": "voice_assistant/run", - "intent_input": "Are the lights on?", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, } ) @@ -52,7 +128,7 @@ async def test_text_only_pipeline( } msg = await client.receive_json() - assert msg["event"]["type"] == "intent-finish" + assert msg["event"]["type"] == "intent-end" assert msg["event"]["data"] == { "intent_output": { "response": { @@ -71,13 +147,120 @@ async def test_text_only_pipeline( } } - # run finish + # run end msg = await client.receive_json() - assert msg["event"]["type"] == "run-finish" + assert msg["event"]["type"] == "run-end" assert msg["event"]["data"] == {} -async def test_conversation_timeout( +async def test_audio_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with audio input/output.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == { + "engine": "default", + "metadata": { + "bit_rate": 16, + "channel": 1, + "codec": "pcm", + "format": "wav", + "language": "en", + "sample_rate": 16000, + }, + } + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-end" + assert msg["event"]["data"] == { + "stt_output": {"text": _TRANSCRIPT}, + } + + # intent + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": _TRANSCRIPT, + } + + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-end" + assert msg["event"]["data"] == { + "intent_output": { + "response": { + "speech": { + "plain": { + "speech": "Sorry, I couldn't understand that", + "extra_data": None, + } + }, + "card": {}, + "language": "en", + "response_type": "error", + "data": {"code": "no_intent_match"}, + }, + "conversation_id": None, + } + } + + # text to speech + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == { + "engine": "default", + "tts_input": "Sorry, I couldn't understand that", + } + + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-end" + assert msg["event"]["data"] == { + "tts_output": { + "url": f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3", + "mime_type": "audio/mpeg", + }, + } + + # run end + msg = await client.receive_json() + assert msg["event"]["type"] == "run-end" + assert msg["event"]["data"] == {} + + +async def test_intent_timeout( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components ) -> None: """Test partial pipeline run with conversation agent timeout.""" @@ -94,7 +277,9 @@ async def test_conversation_timeout( { "id": 5, "type": "voice_assistant/run", - "intent_input": "Are the lights on?", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, "timeout": 0.00001, } ) @@ -125,24 +310,26 @@ async def test_conversation_timeout( assert msg["error"]["code"] == "timeout" -async def test_pipeline_timeout( +async def test_text_pipeline_timeout( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components ) -> None: - """Test pipeline run with immediate timeout.""" + """Test text-only pipeline run with immediate timeout.""" client = await hass_ws_client(hass) async def sleepy_run(*args, **kwargs): await asyncio.sleep(3600) with patch( - "homeassistant.components.voice_assistant.pipeline.TextPipelineRequest._execute", + "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", new=sleepy_run, ): await client.send_json( { "id": 5, "type": "voice_assistant/run", - "intent_input": "Are the lights on?", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, "timeout": 0.0001, } ) @@ -155,3 +342,273 @@ async def test_pipeline_timeout( msg = await client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "timeout" + + +async def test_intent_failed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test text-only pipeline run with conversation agent error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.conversation.async_converse", + new=MagicMock(return_value=RuntimeError), + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "intent", + "end_stage": "intent", + "input": {"text": "Are the lights on?"}, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # intent start + msg = await client.receive_json() + assert msg["event"]["type"] == "intent-start" + assert msg["event"]["data"] == { + "engine": "default", + "intent_input": "Are the lights on?", + } + + # intent error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "intent-failed" + + +async def test_audio_pipeline_timeout( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test audio pipeline run with immediate timeout.""" + client = await hass_ws_client(hass) + + async def sleepy_run(*args, **kwargs): + await asyncio.sleep(3600) + + with patch( + "homeassistant.components.voice_assistant.pipeline.PipelineInput._execute", + new=sleepy_run, + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + "timeout": 0.0001, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # timeout error + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "timeout" + + +async def test_stt_provider_missing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with a non-existent STT provider.""" + with patch( + "homeassistant.components.stt.async_get_provider", + new=MagicMock(return_value=None), + ): + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == { + "engine": "default", + "metadata": { + "bit_rate": 16, + "channel": 1, + "codec": "pcm", + "format": "wav", + "language": "en", + "sample_rate": 16000, + }, + } + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + # stt error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "stt-provider-missing" + + +async def test_stt_stream_failed( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test events from a pipeline run with a non-existent STT provider.""" + with patch( + "tests.components.voice_assistant.test_websocket.MockSttProvider.async_process_audio_stream", + new=MagicMock(side_effect=RuntimeError), + ): + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "stt", + "end_stage": "tts", + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # handler id + msg = await client.receive_json() + assert msg["event"]["handler_id"] == 1 + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # stt + msg = await client.receive_json() + assert msg["event"]["type"] == "stt-start" + assert msg["event"]["data"] == { + "engine": "default", + "metadata": { + "bit_rate": 16, + "channel": 1, + "codec": "pcm", + "format": "wav", + "language": "en", + "sample_rate": 16000, + }, + } + + # End of audio stream (handler id + empty payload) + await client.send_bytes(b"1") + + # stt error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "stt-stream-failed" + + +async def test_tts_failed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test pipeline run with text to speech error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.media_source.async_resolve_media", + new=MagicMock(return_value=RuntimeError), + ): + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert msg["success"] + + # run start + msg = await client.receive_json() + assert msg["event"]["type"] == "run-start" + assert msg["event"]["data"] == { + "pipeline": hass.config.language, + "language": hass.config.language, + } + + # tts start + msg = await client.receive_json() + assert msg["event"]["type"] == "tts-start" + assert msg["event"]["data"] == { + "engine": "default", + "tts_input": "Lights are on.", + } + + # tts error + msg = await client.receive_json() + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "tts-failed" + + +async def test_invalid_stage_order( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components +) -> None: + """Test pipeline run with invalid stage order.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "voice_assistant/run", + "start_stage": "tts", + "end_stage": "stt", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] From 38a4f08e157c5992510b8f972ac7ee2fdfc9ddde Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Mar 2023 20:10:51 +0100 Subject: [PATCH 0725/1058] Fix missing mock in islamic_prayer_times (#90178) * Fix missing mock in islamic_prayer_times * Restore 100% coverage * Update test_config_flow.py --- .../islamic_prayer_times/conftest.py | 15 +++++++++++ .../islamic_prayer_times/test_config_flow.py | 25 ++++++------------- .../islamic_prayer_times/test_init.py | 21 ++++++++++++++++ 3 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 tests/components/islamic_prayer_times/conftest.py diff --git a/tests/components/islamic_prayer_times/conftest.py b/tests/components/islamic_prayer_times/conftest.py new file mode 100644 index 000000000000..63c6ad8414bc --- /dev/null +++ b/tests/components/islamic_prayer_times/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the islamic_prayer_times 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.islamic_prayer_times.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index 664309387343..a25b8ba0f0b3 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,15 +1,15 @@ """Tests for Islamic Prayer Times config flow.""" -from unittest.mock import patch +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD, DOMAIN from homeassistant.core import HomeAssistant -from . import PRAYER_TIMES - from tests.common import MockConfigEntry +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + async def test_flow_works(hass: HomeAssistant) -> None: """Test user config.""" @@ -19,13 +19,11 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.islamic_prayer_times.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Islamic Prayer Times" @@ -40,13 +38,6 @@ async def test_options(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index d641a22590d9..b1cf8f2c9a5f 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import islamic_prayer_times +from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD from homeassistant.core import HomeAssistant from . import ( @@ -85,6 +86,26 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert islamic_prayer_times.DOMAIN not in hass.data +async def test_options_listener(hass: HomeAssistant) -> None: + """Ensure updating options triggers a coordinator refresh.""" + entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry.add_to_hass(hass) + + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ) as mock_fetch_prayer_times: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert mock_fetch_prayer_times.call_count == 1 + + hass.config_entries.async_update_entry( + entry, options={CONF_CALC_METHOD: "makkah"} + ) + await hass.async_block_till_done() + assert mock_fetch_prayer_times.call_count == 2 + + async def test_islamic_prayer_times_timestamp_format(hass: HomeAssistant) -> None: """Test Islamic prayer times timestamp format.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) From dd0f05b98069cc331b57614bcdab4e7a4b174003 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 09:55:02 -1000 Subject: [PATCH 0726/1058] Avoid calling the http access logging when logging is disabled (#90152) --- homeassistant/components/http/__init__.py | 24 ++++++++- tests/components/http/test_init.py | 60 ++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 04b94dc3b819..3106eea05faa 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -12,6 +12,7 @@ from typing import Any, Final, TypedDict, cast from aiohttp import web from aiohttp.typedefs import StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection +from aiohttp.web_log import AccessLogger from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -220,6 +221,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +class HomeAssistantAccessLogger(AccessLogger): + """Access logger for Home Assistant that does not log when disabled.""" + + def log( + self, request: web.BaseRequest, response: web.StreamResponse, time: float + ) -> None: + """Log the request. + + The default implementation logs the request to the logger + with the INFO level and than throws it away if the logger + is not enabled for the INFO level. This implementation + does not log the request if the logger is not enabled for + the INFO level. + """ + if not self.logger.isEnabledFor(logging.INFO): + return + super().log(request, response, time) + + class HomeAssistantHTTP: """HTTP server for Home Assistant.""" @@ -462,7 +482,9 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner(self.app) + self.runner = web.AppRunner( + self.app, access_log_class=HomeAssistantAccessLogger + ) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 578fcc60c7c6..0c346ab947c0 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,10 +1,12 @@ """The tests for the Home Assistant HTTP component.""" +import asyncio from datetime import timedelta from http import HTTPStatus from ipaddress import ip_network import logging import pathlib -from unittest.mock import Mock, patch +import time +from unittest.mock import MagicMock, Mock, patch import py import pytest @@ -20,6 +22,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.ssl import server_context_intermediate, server_context_modern from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMockResponse from tests.typing import ClientSessionGenerator @@ -463,3 +466,58 @@ async def test_storing_config( restored["trusted_proxies"][0] = ip_network(restored["trusted_proxies"][0]) assert restored == http.HTTP_SCHEMA(config) + + +async def test_logging( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Testing the access log works.""" + await asyncio.gather( + *( + async_setup_component(hass, component, {}) + for component in ("http", "logger", "api") + ) + ) + hass.states.async_set("logging.entity", "hello") + await hass.services.async_call( + "logger", + "set_level", + {"aiohttp.access": "info"}, + blocking=True, + ) + client = await hass_client() + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK + + assert "GET /api/states/logging.entity" in caplog.text + caplog.clear() + await hass.services.async_call( + "logger", + "set_level", + {"aiohttp.access": "warning"}, + blocking=True, + ) + response = await client.get("/api/states/logging.entity") + assert response.status == HTTPStatus.OK + assert "GET /api/states/logging.entity" not in caplog.text + + +async def test_hass_access_logger_at_info_level( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that logging happens at info level.""" + test_logger = logging.getLogger("test.aiohttp.logger") + logger = http.HomeAssistantAccessLogger(test_logger) + mock_request = MagicMock() + response = AiohttpClientMockResponse( + "POST", "http://127.0.0.1", status=HTTPStatus.OK + ) + setattr(response, "body_length", 42) + logger.log(mock_request, response, time.time()) + assert "42" in caplog.text + caplog.clear() + test_logger.setLevel(logging.WARNING) + logger.log(mock_request, response, time.time()) + assert "42" not in caplog.text From d49fbc17dfbfad9821779bd2fa9277b67263c1e1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:52:37 -1000 Subject: [PATCH 0727/1058] Fix recorder attribute excludes not being effective until after startup (#90198) * Fix attribute excludes not being effective until after startup fixes #90016 * reduce --- homeassistant/components/recorder/__init__.py | 41 ++++++++++++++++--- homeassistant/components/recorder/const.py | 13 ++++++ .../components/recorder/statistics.py | 26 ++++++++---- homeassistant/components/recorder/tasks.py | 5 +-- tests/components/recorder/test_init.py | 8 +++- 5 files changed, 75 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 2621db9cb700..750f504d0964 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,6 +28,9 @@ from .const import ( # noqa: F401 EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, EXCLUDE_ATTRIBUTES, + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, + INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD, SQLITE_URL_PREFIX, ) from .core import Recorder @@ -165,14 +168,40 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_services(hass, instance) websocket_api.async_setup(hass) entity_registry.async_setup(hass) - await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) + + await _async_setup_integration_platform( + hass, instance, exclude_attributes_by_domain + ) return await instance.async_db_ready -async def _process_recorder_platform( - hass: HomeAssistant, domain: str, platform: Any +async def _async_setup_integration_platform( + hass: HomeAssistant, + instance: Recorder, + exclude_attributes_by_domain: dict[str, set[str]], ) -> None: - """Process a recorder platform.""" - instance = get_instance(hass) - instance.queue_task(AddRecorderPlatformTask(domain, platform)) + """Set up a recorder integration platform.""" + + async def _process_recorder_platform( + hass: HomeAssistant, domain: str, platform: Any + ) -> None: + """Process a recorder platform.""" + # We need to add this before as soon as the component is loaded + # to ensure by the time the state is recorded that the excluded + # attributes are known. This is safe to modify in the event loop + # since exclude_attributes_by_domain is never iterated over. + if exclude_attributes := getattr( + platform, INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES, None + ): + exclude_attributes_by_domain[domain] = exclude_attributes(hass) + + # If the platform has a compile_statistics method, we need to + # add it to the recorder queue to be processed. + if any( + hasattr(platform, _attr) + for _attr in INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD + ): + instance.queue_task(AddRecorderPlatformTask(domain, platform)) + + await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 6bf46efd3608..fbec19a2d1ee 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -51,6 +51,19 @@ STATES_META_SCHEMA_VERSION = 38 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 +INTEGRATION_PLATFORM_EXCLUDE_ATTRIBUTES = "exclude_attributes" + +INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" +INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics" +INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" + +INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD = { + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, + INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, +} + + class SupportedDialect(StrEnum): """Supported dialects.""" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 82fbf7798f97..8025616d2467 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -47,6 +47,9 @@ from .const import ( DOMAIN, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, + INTEGRATION_PLATFORM_COMPILE_STATISTICS, + INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, SupportedDialect, ) from .db_schema import ( @@ -502,9 +505,13 @@ def _compile_statistics( current_metadata: dict[str, tuple[int, StatisticMetaData]] = {} # Collect statistics from all platforms implementing support for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items(): - if not hasattr(platform, "compile_statistics"): + if not ( + platform_compile_statistics := getattr( + platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None + ) + ): continue - compiled: PlatformCompiledStatistics = platform.compile_statistics( + compiled: PlatformCompiledStatistics = platform_compile_statistics( instance.hass, start, end ) _LOGGER.debug( @@ -783,9 +790,13 @@ def list_statistic_ids( # # Query all integrations with a registered recorder platform for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "list_statistic_ids"): + if not ( + platform_list_statistic_ids := getattr( + platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None + ) + ): continue - platform_statistic_ids = platform.list_statistic_ids( + platform_statistic_ids = platform_list_statistic_ids( hass, statistic_ids=statistic_ids, statistic_type=statistic_type ) @@ -1931,9 +1942,10 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]] """Validate statistics.""" platform_validation: dict[str, list[ValidationIssue]] = {} for platform in hass.data[DOMAIN].recorder_platforms.values(): - if not hasattr(platform, "validate_statistics"): - continue - platform_validation.update(platform.validate_statistics(hass)) + if platform_validate_statistics := getattr( + platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None + ): + platform_validation.update(platform_validate_statistics(hass)) return platform_validation diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 7b8fa4867b6f..ef1188570597 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -14,7 +14,7 @@ from homeassistant.core import Event from homeassistant.helpers.typing import UndefinedType from . import entity_registry, purge, statistics -from .const import DOMAIN, EXCLUDE_ATTRIBUTES +from .const import DOMAIN from .db_schema import Statistics, StatisticsShortTerm from .models import StatisticData, StatisticMetaData from .util import periodic_db_cleanups @@ -317,11 +317,8 @@ class AddRecorderPlatformTask(RecorderTask): hass = instance.hass domain = self.domain platform = self.platform - platforms: dict[str, Any] = hass.data[DOMAIN].recorder_platforms platforms[domain] = platform - if hasattr(self.platform, "exclude_attributes"): - hass.data[EXCLUDE_ATTRIBUTES][domain] = platform.exclude_attributes(hass) @dataclass diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 3232b10fdce8..8fb45cb3d4d2 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -2112,10 +2112,15 @@ async def test_connect_args_priority(hass: HomeAssistant, config_url) -> None: assert connect_params[0]["charset"] == "utf8mb4" +@pytest.mark.parametrize("core_state", [CoreState.starting, CoreState.running]) async def test_excluding_attributes_by_integration( - recorder_mock: Recorder, hass: HomeAssistant, entity_registry: er.EntityRegistry + recorder_mock: Recorder, + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + core_state: CoreState, ) -> None: """Test that an integration's recorder platform can exclude attributes.""" + hass.state = core_state state = "restoring_from_db" attributes = {"test_attr": 5, "excluded": 10} mock_platform( @@ -2131,6 +2136,7 @@ async def test_excluding_attributes_by_integration( platform = MockEntityPlatform(hass, platform_name="fake_integration") entity_platform = MockEntity(entity_id=entity_id, extra_state_attributes=attributes) await platform.async_add_entities([entity_platform]) + await hass.async_block_till_done() await async_wait_recording_done(hass) From e7e7f603c24dda93c114968c36ae9fc890971bb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:53:18 -1000 Subject: [PATCH 0728/1058] Remove async_response from websocket apis where nothing was being awaited (#90204) --- homeassistant/components/logger/websocket_api.py | 4 ++-- homeassistant/components/websocket_api/commands.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 1b4e5cb36a68..89026a07b8a0 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -27,9 +27,9 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, handle_module_log_level) +@callback @websocket_api.websocket_command({vol.Required("type"): "logger/log_info"}) -@websocket_api.async_response -async def handle_integration_log_info( +def handle_integration_log_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations logger info.""" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index fa5c6aac2944..2b146d944724 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -399,9 +399,9 @@ async def handle_manifest_get( connection.send_error(msg["id"], const.ERR_NOT_FOUND, "Integration not found") +@callback @decorators.websocket_command({vol.Required("type"): "integration/setup_info"}) -@decorators.async_response -async def handle_integration_setup_info( +def handle_integration_setup_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" @@ -648,6 +648,7 @@ async def handle_execute_script( connection.send_result(msg["id"], {"context": context}) +@callback @decorators.websocket_command( { vol.Required("type"): "fire_event", @@ -656,8 +657,7 @@ async def handle_execute_script( } ) @decorators.require_admin -@decorators.async_response -async def handle_fire_event( +def handle_fire_event( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle fire event command.""" From a44d6f30c9744241807f63d6c0659786d8f270da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:56:04 -1000 Subject: [PATCH 0729/1058] Fix refactoring error in states/events context id migration (#90193) fixes #90074 --- homeassistant/components/recorder/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index bbdab2690d17..8e522a2bbd9d 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -718,7 +718,7 @@ class Recorder(threading.Thread): if ( self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION or execute_stmt_lambda_element( - session, has_events_context_ids_to_migrate() + session, has_states_context_ids_to_migrate() ) ): self.queue_task(StatesContextIDMigrationTask()) @@ -726,7 +726,7 @@ class Recorder(threading.Thread): if ( self.schema_version < CONTEXT_ID_AS_BINARY_SCHEMA_VERSION or execute_stmt_lambda_element( - session, has_states_context_ids_to_migrate() + session, has_events_context_ids_to_migrate() ) ): self.queue_task(EventsContextIDMigrationTask()) From f1ec77b8e07eb9c8fd140a32327f6085040ad355 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 14:56:58 -1000 Subject: [PATCH 0730/1058] Small cleanups to logbook statement generator (#90200) We should only convert the context id to binary if its going to be used. Avoid some intermediate vars that are no longer needed --- .../components/logbook/queries/__init__.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index cfef16bf7735..0172700df437 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -30,10 +30,10 @@ def statement_for_request( """Generate the logbook statement for a logbook request.""" start_day = dt_util.utc_to_timestamp(start_day_dt) end_day = dt_util.utc_to_timestamp(end_day_dt) - context_id_bin = ulid_to_bytes_or_none(context_id) # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: + context_id_bin = ulid_to_bytes_or_none(context_id) states_entity_filter = ( filters.states_metadata_entity_filter() if filters else None ) @@ -54,34 +54,30 @@ def statement_for_request( # entities and devices: logbook sends everything for the timeframe for the entities and devices if entity_ids and device_ids: - json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] - json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return entities_devices_stmt( start_day, end_day, event_types, states_metadata_ids or [], - json_quoted_entity_ids, - json_quoted_device_ids, + [json_dumps(entity_id) for entity_id in entity_ids], + [json_dumps(device_id) for device_id in device_ids], ) # entities: logbook sends everything for the timeframe for the entities if entity_ids: - json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] return entities_stmt( start_day, end_day, event_types, states_metadata_ids or [], - json_quoted_entity_ids, + [json_dumps(entity_id) for entity_id in entity_ids], ) # devices: logbook sends everything for the timeframe for the devices assert device_ids is not None - json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return devices_stmt( start_day, end_day, event_types, - json_quoted_device_ids, + [json_dumps(device_id) for device_id in device_ids], ) From ca157f4d19edfa30c52a32d02439a26efdf576a7 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Fri, 24 Mar 2023 02:23:05 -0400 Subject: [PATCH 0731/1058] Add icons for qbittorrent speed sensors (#90203) add icons for qbittorrent speed sensors --- homeassistant/components/qbittorrent/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index e7b75954d52e..26605a876523 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -44,6 +44,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, name="Down Speed", + icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, @@ -51,6 +52,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", + icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, From 1f2268a878f095bda28cf8ee09da6c68007c90a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Mar 2023 21:40:47 -1000 Subject: [PATCH 0732/1058] Fix httpx client creating a new ssl context with each client (memory leak) (#90191) * Fix httpx client creating a new ssl context with each client While working on https://github.com/home-assistant/core/issues/83524 it was discovered that each new httpx client creates a new ssl context https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_transports/default.py#L261 If an ssl context is passed in creating a new one is avoided here https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_config.py#L110 This change makes httpx ssl no-verify behavior match aiohttp ssl no-verify behavior https://github.com/aio-libs/aiohttp/blob/6da04694fd87a39af9c3856048c9ff23ca815f88/aiohttp/connector.py#L892 aiohttp solved this by wrapping the code that generates the ssl context in an lru_cache * compact --- homeassistant/helpers/aiohttp_client.py | 2 +- homeassistant/helpers/httpx_client.py | 7 +++++-- homeassistant/util/ssl.py | 27 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 53c3cc1cf222..78a8051df1cc 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -273,7 +273,7 @@ def _async_get_connector( if verify_ssl: ssl_context: bool | SSLContext = ssl_util.get_default_context() else: - ssl_context = False + ssl_context = ssl_util.get_default_no_verify_context() connector = aiohttp.TCPConnector( enable_cleanup_closed=True, diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index 1e9d2e776c6b..44ad81c73e90 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -11,7 +11,7 @@ from typing_extensions import Self from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.loader import bind_hass -from homeassistant.util import ssl as ssl_util +from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .frame import warn_use @@ -65,8 +65,11 @@ def create_async_httpx_client( This method must be run in the event loop. """ + ssl_context = ( + get_default_context() if verify_ssl else get_default_no_verify_context() + ) client = HassHttpXAsyncClient( - verify=ssl_util.get_default_context() if verify_ssl else False, + verify=ssl_context, headers={USER_AGENT: SERVER_SOFTWARE}, **kwargs, ) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 9c945ef27596..5b8830e6571f 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -1,10 +1,31 @@ """Helper to create SSL contexts.""" +import contextlib from os import environ import ssl import certifi +def create_no_verify_ssl_context() -> ssl.SSLContext: + """Return an SSL context that does not verify the server certificate. + + This is a copy of aiohttp's create_default_context() function, with the + ssl verify turned off. + + https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 + """ + sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + sslcontext.options |= ssl.OP_NO_SSLv2 + sslcontext.options |= ssl.OP_NO_SSLv3 + sslcontext.check_hostname = False + sslcontext.verify_mode = ssl.CERT_NONE + with contextlib.suppress(AttributeError): + # This only works for OpenSSL >= 1.0.0 + sslcontext.options |= ssl.OP_NO_COMPRESSION + sslcontext.set_default_verify_paths() + return sslcontext + + def client_context() -> ssl.SSLContext: """Return an SSL context for making requests.""" @@ -18,6 +39,7 @@ def client_context() -> ssl.SSLContext: # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = client_context() +_DEFAULT_NO_VERIFY_SSL_CONTEXT = create_no_verify_ssl_context() def get_default_context() -> ssl.SSLContext: @@ -25,6 +47,11 @@ def get_default_context() -> ssl.SSLContext: return _DEFAULT_SSL_CONTEXT +def get_default_no_verify_context() -> ssl.SSLContext: + """Return the default SSL context that does not verify the server certificate.""" + return _DEFAULT_NO_VERIFY_SSL_CONTEXT + + def server_context_modern() -> ssl.SSLContext: """Return an SSL context following the Mozilla recommendations. From a404d5f6d74783463db0cb5c944e467ba9d19d37 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:41:04 +0100 Subject: [PATCH 0733/1058] Prepare MQTT platform tests part5 (#90108) * Tests light_json * Tests light_template * Tests light --- tests/components/mqtt/test_light.py | 1217 ++++++++++-------- tests/components/mqtt/test_light_json.py | 635 ++++----- tests/components/mqtt/test_light_template.py | 323 ++--- 3 files changed, 1175 insertions(+), 1000 deletions(-) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 7d0c0333b7ef..6ad52103a06e 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -196,7 +196,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -243,26 +242,26 @@ def light_platform_only(): yield +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {light.DOMAIN: {"name": "test"}}}] +) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - assert not await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {light.DOMAIN: {"name": "test"}}} - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." in caplog.text ) -async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test if there is no color and brightness if no topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -271,10 +270,14 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( "command_topic": "test_light_rgb/set", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test if there is no color and brightness if no topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -315,41 +318,46 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_state_topic": "test_light_rgb/effect/status", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_state_topic": "test_light_rgb/xy/status", + "xy_command_topic": "test_light_rgb/xy/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the controlling of the state via topic.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_state_topic": "test_light_rgb/effect/status", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_state_topic": "test_light_rgb/xy/status", - "xy_command_topic": "test_light_rgb/xy/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -436,43 +444,47 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "color_mode_state_topic": "test_light_rgb/color_mode/status", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_state_topic": "test_light_rgb/effect/status", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_state_topic": "test_light_rgb/xy/status", + "xy_command_topic": "test_light_rgb/xy/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_invalid_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test handling of empty data via topic.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "color_mode_state_topic": "test_light_rgb/color_mode/status", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_state_topic": "test_light_rgb/effect/status", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_state_topic": "test_light_rgb/xy/status", - "xy_command_topic": "test_light_rgb/xy/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -564,13 +576,9 @@ async def test_invalid_state_via_topic( assert light_state.attributes["color_temp"] == 153 -async def test_brightness_controlling_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the brightness controlling scale.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -585,10 +593,14 @@ async def test_brightness_controlling_scale( "payload_off": "off", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_brightness_controlling_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the brightness controlling scale.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -614,13 +626,9 @@ async def test_brightness_controlling_scale( assert light_state.attributes["brightness"] == 255 -async def test_brightness_from_rgb_controlling_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the brightness controlling scale.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -634,9 +642,14 @@ async def test_brightness_from_rgb_controlling_scale( "payload_off": "off", } } - }, - ) - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_brightness_from_rgb_controlling_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the brightness controlling scale.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() state = hass.states.get("light.test") @@ -675,47 +688,52 @@ async def test_brightness_from_rgb_controlling_scale( assert state.attributes.get("rgb_color") == (255, 127, 63) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_command_topic": "test_light_rgb/rgbw/set", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_command_topic": "test_light_rgb/xy/set", + "brightness_state_topic": "test_light_rgb/brightness/status", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "effect_state_topic": "test_light_rgb/effect/status", + "hs_state_topic": "test_light_rgb/hs/status", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "xy_state_topic": "test_light_rgb/xy/status", + "state_value_template": "{{ value_json.hello }}", + "brightness_value_template": "{{ value_json.hello }}", + "color_temp_value_template": "{{ value_json.hello }}", + "effect_value_template": "{{ value_json.hello }}", + "hs_value_template": '{{ value_json.hello | join(",") }}', + "rgb_value_template": '{{ value_json.hello | join(",") }}', + "rgbw_value_template": '{{ value_json.hello | join(",") }}', + "rgbww_value_template": '{{ value_json.hello | join(",") }}', + "xy_value_template": '{{ value_json.hello | join(",") }}', + } + } + } + ], +) async def test_controlling_state_via_topic_with_templates( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the setting of the state with a template.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_command_topic": "test_light_rgb/rgbw/set", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_command_topic": "test_light_rgb/xy/set", - "brightness_state_topic": "test_light_rgb/brightness/status", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "effect_state_topic": "test_light_rgb/effect/status", - "hs_state_topic": "test_light_rgb/hs/status", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "xy_state_topic": "test_light_rgb/xy/status", - "state_value_template": "{{ value_json.hello }}", - "brightness_value_template": "{{ value_json.hello }}", - "color_temp_value_template": "{{ value_json.hello }}", - "effect_value_template": "{{ value_json.hello }}", - "hs_value_template": '{{ value_json.hello | join(",") }}', - "rgb_value_template": '{{ value_json.hello | join(",") }}', - "rgbw_value_template": '{{ value_json.hello | join(",") }}', - "rgbww_value_template": '{{ value_json.hello | join(",") }}', - "xy_value_template": '{{ value_json.hello | join(",") }}', - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -777,28 +795,35 @@ async def test_controlling_state_via_topic_with_templates( assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_command_topic": "test_light_rgb/xy/set", + "effect_list": ["colorloop", "random"], + "qos": 2, + "payload_on": "on", + "payload_off": "off", + } + } + } + ], +) async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_command_topic": "test_light_rgb/xy/set", - "effect_list": ["colorloop", "random"], - "qos": 2, - "payload_on": "on", - "payload_off": "off", - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] fake_state = State( "light.test", @@ -813,9 +838,7 @@ async def test_sending_mqtt_commands_and_optimistic( ) mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -966,26 +989,30 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgb_command_template": '{{ "#%02x%02x%02x" | ' + "format(red, green, blue)}}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_rgb_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of RGB command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgb_command_template": '{{ "#%02x%02x%02x" | ' - "format(red, green, blue)}}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1005,26 +1032,30 @@ async def test_sending_mqtt_rgb_command_with_template( assert state.attributes["rgb_color"] == (255, 128, 64) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbw_command_template": '{{ "#%02x%02x%02x%02x" | ' + "format(red, green, blue, white)}}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_rgbw_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of RGBW command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbw_command_template": '{{ "#%02x%02x%02x%02x" | ' - "format(red, green, blue, white)}}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1044,26 +1075,30 @@ async def test_sending_mqtt_rgbw_command_with_template( assert state.attributes["rgbw_color"] == (255, 128, 64, 32) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_rgb/set", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "rgbww_command_template": '{{ "#%02x%02x%02x%02x%02x" | ' + "format(red, green, blue, cold_white, warm_white)}}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_rgbww_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of RGBWW command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_rgb/set", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "rgbww_command_template": '{{ "#%02x%02x%02x%02x%02x" | ' - "format(red, green, blue, cold_white, warm_white)}}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1083,25 +1118,29 @@ async def test_sending_mqtt_rgbww_command_with_template( assert state.attributes["rgbww_color"] == (255, 128, 64, 32, 16) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_color_temp/set", + "color_temp_command_topic": "test_light_color_temp/color_temp/set", + "color_temp_command_template": "{{ (1000 / value) | round(0) }}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_color_temp_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of Color Temp command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_color_temp/set", - "color_temp_command_topic": "test_light_color_temp/color_temp/set", - "color_temp_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1121,22 +1160,26 @@ async def test_sending_mqtt_color_temp_command_with_template( assert state.attributes["color_temp"] == 100 +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + "on_command_type": "first", + } + } + } + ], +) async def test_on_command_first( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command being sent before brightness.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - "on_command_type": "first", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1159,21 +1202,25 @@ async def test_on_command_first( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + } + } + } + ], +) async def test_on_command_last( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command being sent after brightness.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1196,23 +1243,27 @@ async def test_on_command_last( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + "rgb_command_topic": "test_light/rgb", + "on_command_type": "brightness", + } + } + } + ], +) async def test_on_command_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command being sent as only brightness.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - "rgb_command_topic": "test_light/rgb", - "on_command_type": "brightness", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1253,24 +1304,28 @@ async def test_on_command_brightness( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "brightness_command_topic": "test_light/bright", + "brightness_scale": 100, + "rgb_command_topic": "test_light/rgb", + "on_command_type": "brightness", + } + } + } + ], +) async def test_on_command_brightness_scaled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test brightness scale.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "brightness_command_topic": "test_light/bright", - "brightness_scale": 100, - "rgb_command_topic": "test_light/rgb", - "on_command_type": "brightness", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1325,21 +1380,25 @@ async def test_on_command_brightness_scaled( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgb_command_topic": "test_light/rgb", + } + } + } + ], +) async def test_on_command_rgb( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGB brightness mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgb_command_topic": "test_light/rgb", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1417,21 +1476,25 @@ async def test_on_command_rgb( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbw_command_topic": "test_light/rgbw", + } + } + } + ], +) async def test_on_command_rgbw( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBW brightness mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbw_command_topic": "test_light/rgbw", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1509,21 +1572,25 @@ async def test_on_command_rgbw( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbww_command_topic": "test_light/rgbww", + } + } + } + ], +) async def test_on_command_rgbww( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBWW brightness mode.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbww_command_topic": "test_light/rgbww", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1601,22 +1668,26 @@ async def test_on_command_rgbww( mqtt_mock.async_publish.reset_mock() +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgb_command_topic": "test_light/rgb", + "rgb_command_template": "{{ red }}/{{ green }}/{{ blue }}", + } + } + } + ], +) async def test_on_command_rgb_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGB brightness mode with RGB template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgb_command_topic": "test_light/rgb", - "rgb_command_template": "{{ red }}/{{ green }}/{{ blue }}", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1640,22 +1711,26 @@ async def test_on_command_rgb_template( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbw_command_topic": "test_light/rgbw", + "rgbw_command_template": "{{ red }}/{{ green }}/{{ blue }}/{{ white }}", + } + } + } + ], +) async def test_on_command_rgbw_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBW brightness mode with RGBW template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbw_command_topic": "test_light/rgbw", - "rgbw_command_template": "{{ red }}/{{ green }}/{{ blue }}/{{ white }}", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1678,22 +1753,27 @@ async def test_on_command_rgbw_template( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "rgbww_command_topic": "test_light/rgbww", + "rgbww_command_template": "{{ red }}/{{ green }}/{{ blue }}" + "/{{ cold_white }}/{{ warm_white }}", + } + } + } + ], +) async def test_on_command_rgbww_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test on command in RGBWW brightness mode with RGBWW template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "rgbww_command_topic": "test_light/rgbww", - "rgbww_command_template": "{{ red }}/{{ green }}/{{ blue }}/{{ cold_white }}/{{ warm_white }}", - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1717,34 +1797,39 @@ async def test_on_command_rgbww_template( mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "tasmota_B94927/cmnd/POWER", + "state_value_template": "{{ value_json.POWER }}", + "payload_off": "OFF", + "payload_on": "ON", + "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", + "brightness_scale": 100, + "on_command_type": "brightness", + "brightness_value_template": "{{ value_json.Dimmer }}", + "rgb_command_topic": "tasmota_B94927/cmnd/Color2", + "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", + "white_command_topic": "tasmota_B94927/cmnd/White", + "white_scale": 100, + "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", + "qos": "0", + } + } + } + ], +) async def test_on_command_white( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test sending commands for RGB + white light.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "tasmota_B94927/cmnd/POWER", - "state_value_template": "{{ value_json.POWER }}", - "payload_off": "OFF", - "payload_on": "ON", - "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", - "brightness_scale": 100, - "on_command_type": "brightness", - "brightness_value_template": "{{ value_json.Dimmer }}", - "rgb_command_topic": "tasmota_B94927/cmnd/Color2", - "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", - "white_command_topic": "tasmota_B94927/cmnd/White", - "white_scale": 100, - "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", - "qos": "0", - } - } color_modes = ["rgb", "white"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1796,42 +1881,47 @@ async def test_on_command_white( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "color_mode_state_topic": "test_light_rgb/color_mode/status", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "rgb_state_topic": "test_light_rgb/rgb/status", + "rgb_command_topic": "test_light_rgb/rgb/set", + "rgbw_state_topic": "test_light_rgb/rgbw/status", + "rgbw_command_topic": "test_light_rgb/rgbw/set", + "rgbww_state_topic": "test_light_rgb/rgbww/status", + "rgbww_command_topic": "test_light_rgb/rgbww/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "effect_state_topic": "test_light_rgb/effect/status", + "effect_command_topic": "test_light_rgb/effect/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "xy_state_topic": "test_light_rgb/xy/status", + "xy_command_topic": "test_light_rgb/xy/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_explicit_color_mode( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test explicit color mode over mqtt.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "color_mode_state_topic": "test_light_rgb/color_mode/status", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "rgb_state_topic": "test_light_rgb/rgb/status", - "rgb_command_topic": "test_light_rgb/rgb/set", - "rgbw_state_topic": "test_light_rgb/rgbw/status", - "rgbw_command_topic": "test_light_rgb/rgbw/set", - "rgbww_state_topic": "test_light_rgb/rgbww/status", - "rgbww_command_topic": "test_light_rgb/rgbww/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "effect_state_topic": "test_light_rgb/effect/status", - "effect_command_topic": "test_light_rgb/effect/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "xy_state_topic": "test_light_rgb/xy/status", - "xy_command_topic": "test_light_rgb/xy/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1946,33 +2036,38 @@ async def test_explicit_color_mode( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "test_light_rgb/status", + "command_topic": "test_light_rgb/set", + "color_mode_state_topic": "test_light_rgb/color_mode/status", + "color_mode_value_template": "{{ value_json.color_mode }}", + "brightness_state_topic": "test_light_rgb/brightness/status", + "brightness_command_topic": "test_light_rgb/brightness/set", + "color_temp_state_topic": "test_light_rgb/color_temp/status", + "color_temp_command_topic": "test_light_rgb/color_temp/set", + "hs_state_topic": "test_light_rgb/hs/status", + "hs_command_topic": "test_light_rgb/hs/set", + "qos": "0", + "payload_on": 1, + "payload_off": 0, + } + } + } + ], +) async def test_explicit_color_mode_templated( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test templated explicit color mode over mqtt.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "test_light_rgb/status", - "command_topic": "test_light_rgb/set", - "color_mode_state_topic": "test_light_rgb/color_mode/status", - "color_mode_value_template": "{{ value_json.color_mode }}", - "brightness_state_topic": "test_light_rgb/brightness/status", - "brightness_command_topic": "test_light_rgb/brightness/set", - "color_temp_state_topic": "test_light_rgb/color_temp/status", - "color_temp_command_topic": "test_light_rgb/color_temp/set", - "hs_state_topic": "test_light_rgb/hs/status", - "hs_command_topic": "test_light_rgb/hs/set", - "qos": "0", - "payload_on": 1, - "payload_off": 0, - } - } color_modes = ["color_temp", "hs"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2029,38 +2124,45 @@ async def test_explicit_color_mode_templated( assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "state_topic": "tasmota_B94927/tele/STATE", + "command_topic": "tasmota_B94927/cmnd/POWER", + "state_value_template": "{{ value_json.POWER }}", + "payload_off": "OFF", + "payload_on": "ON", + "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", + "brightness_state_topic": "tasmota_B94927/tele/STATE", + "brightness_scale": 100, + "on_command_type": "brightness", + "brightness_value_template": "{{ value_json.Dimmer }}", + "rgb_command_topic": "tasmota_B94927/cmnd/Color2", + "rgb_state_topic": "tasmota_B94927/tele/STATE", + "rgb_value_template": "{{value_json.Color.split(',')" + "[0:3]|join(',')}}", + "white_command_topic": "tasmota_B94927/cmnd/White", + "white_scale": 100, + "color_mode_state_topic": "tasmota_B94927/tele/STATE", + "color_mode_value_template": "{% if value_json.White %} white " + "{% else %} rgb {% endif %}", + "qos": "0", + } + } + } + ], +) async def test_white_state_update( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test state updates for RGB + white light.""" - config = { - light.DOMAIN: { - "name": "test", - "state_topic": "tasmota_B94927/tele/STATE", - "command_topic": "tasmota_B94927/cmnd/POWER", - "state_value_template": "{{ value_json.POWER }}", - "payload_off": "OFF", - "payload_on": "ON", - "brightness_command_topic": "tasmota_B94927/cmnd/Dimmer", - "brightness_state_topic": "tasmota_B94927/tele/STATE", - "brightness_scale": 100, - "on_command_type": "brightness", - "brightness_value_template": "{{ value_json.Dimmer }}", - "rgb_command_topic": "tasmota_B94927/cmnd/Color2", - "rgb_state_topic": "tasmota_B94927/tele/STATE", - "rgb_value_template": "{{value_json.Color.split(',')[0:3]|join(',')}}", - "white_command_topic": "tasmota_B94927/cmnd/White", - "white_scale": 100, - "color_mode_state_topic": "tasmota_B94927/tele/STATE", - "color_mode_value_template": "{% if value_json.White %} white {% else %} rgb {% endif %}", - "qos": "0", - } - } color_modes = ["rgb", "white"] - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2095,22 +2197,26 @@ async def test_white_state_update( assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light/set", + "effect_command_topic": "test_light/effect/set", + "effect_list": ["rainbow", "colorloop"], + } + } + } + ], +) async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test effect.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light/set", - "effect_command_topic": "test_light/effect/set", - "effect_list": ["rainbow", "colorloop"], - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2893,22 +2999,26 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_max_mireds/set", + "color_temp_command_topic": "test_max_mireds/color_temp/set", + "max_mireds": 370, + } + } + } + ], +) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp_command_topic": "test_max_mireds/color_temp/set", - "max_mireds": 370, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 @@ -3145,25 +3255,29 @@ async def test_encoding_subscribable_topics_brightness( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_brightness/set", + "brightness_command_topic": "test_light_brightness/brightness/set", + "brightness_command_template": "{{ (1000 / value) | round(0) }}", + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_brightness_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of Brightness command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_brightness/set", - "brightness_command_topic": "test_light_brightness/brightness/set", - "brightness_command_template": "{{ (1000 / value) | round(0) }}", - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3183,27 +3297,31 @@ async def test_sending_mqtt_brightness_command_with_template( assert state.attributes["brightness"] == 100 +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_brightness/set", + "brightness_command_topic": "test_light_brightness/brightness/set", + "effect_command_topic": "test_light_brightness/effect/set", + "effect_command_template": '{ "effect": "{{ value }}" }', + "effect_list": ["colorloop", "random"], + "payload_on": "on", + "payload_off": "off", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_effect_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of Effect command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_brightness/set", - "brightness_command_topic": "test_light_brightness/brightness/set", - "effect_command_topic": "test_light_brightness/effect/set", - "effect_command_template": '{ "effect": "{{ value }}" }', - "effect_list": ["colorloop", "random"], - "payload_on": "on", - "payload_off": "off", - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3227,23 +3345,27 @@ async def test_sending_mqtt_effect_command_with_template( assert state.attributes.get("effect") == "colorloop" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_hs/set", + "hs_command_topic": "test_light_hs/hs_color/set", + "hs_command_template": '{"hue": {{ hue | int }}, "sat": {{ sat | int}}}', + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_hs_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of HS Color command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_hs/set", - "hs_command_topic": "test_light_hs/hs_color/set", - "hs_command_template": '{"hue": {{ hue | int }}, "sat": {{ sat | int}}}', - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3263,23 +3385,30 @@ async def test_sending_mqtt_hs_command_with_template( assert state.attributes["hs_color"] == (30, 100) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "name": "test", + "command_topic": "test_light_xy/set", + "xy_command_topic": "test_light_xy/xy_color/set", + "xy_command_template": "{" + '"Color": "{{ (x * 65536) | round | int }},' + '{{ (y * 65536) | round | int }}"' + "}", + "qos": 0, + } + } + } + ], +) async def test_sending_mqtt_xy_command_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of XY Color command with template.""" - config = { - light.DOMAIN: { - "name": "test", - "command_topic": "test_light_xy/set", - "xy_command_topic": "test_light_xy/xy_color/set", - "xy_command_template": '{"Color": "{{ (x * 65536) | round | int }},{{ (y * 65536) | round | int }}"}', - "qos": 0, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index db39cea78443..1b24f7636f56 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -98,10 +98,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from homeassistant.util.json import JsonValueType, json_loads from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -144,6 +144,30 @@ DEFAULT_CONFIG = { } +COLOR_MODES_CONFIG = { + mqtt.DOMAIN: { + light.DOMAIN: { + "brightness": True, + "color_mode": True, + "effect": True, + "command_topic": "test_light_rgb/set", + "name": "test", + "schema": "json", + "supported_color_modes": [ + "color_temp", + "hs", + "rgb", + "rgbw", + "rgbww", + "white", + "xy", + ], + "qos": 0, + } + } +} + + @pytest.fixture(autouse=True) def light_platform_only(): """Only setup the light platform to speed up tests.""" @@ -163,44 +187,40 @@ class JsonValidator: return json_loads(self.jsondata) == json_loads(other) +@pytest.mark.parametrize( + "hass_config", [{mqtt.DOMAIN: {light.DOMAIN: {"schema": "json", "name": "test"}}}] +) async def test_fail_setup_if_no_command_topic( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails with no command topic.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {light.DOMAIN: {"schema": "json", "name": "test"}}}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']. Got None." in caplog.text ) -@pytest.mark.parametrize("deprecated", ("color_temp", "hs", "rgb", "xy")) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"color_temp": True},)), + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"hs": True},)), + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"rgb": True},)), + help_custom_config(light.DOMAIN, COLOR_MODES_CONFIG, ({"xy": True},)), + ], +) async def test_fail_setup_if_color_mode_deprecated( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, deprecated + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails if color mode is combined with deprecated config keys.""" - supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] - - config = { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "name": "test", - "schema": "json", - "supported_color_modes": supported_color_modes, - } - } - config[light.DOMAIN][deprecated] = True - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: config}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert ( "Invalid config for [mqtt]: color_mode must not be combined with any of" in caplog.text @@ -208,42 +228,49 @@ async def test_fail_setup_if_color_mode_deprecated( @pytest.mark.parametrize( - ("supported_color_modes", "error"), + ("hass_config", "error"), [ - (["onoff", "rgb"], "Unknown error calling mqtt CONFIG_SCHEMA"), - (["brightness", "rgb"], "Unknown error calling mqtt CONFIG_SCHEMA"), - (["unknown"], "Invalid config for [mqtt]: value must be one of [ None: """Test if setup fails if supported color modes is invalid.""" - config = { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "name": "test", - "schema": "json", - "supported_color_modes": supported_color_modes, - } - } - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: config}, - ) + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert error in caplog.text -async def test_legacy_rgb_light( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test legacy RGB light flags expected features and color modes.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -253,10 +280,14 @@ async def test_legacy_rgb_light( "rgb": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_legacy_rgb_light( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test legacy RGB light flags expected features and color modes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") color_modes = [light.ColorMode.HS] @@ -265,13 +296,9 @@ async def test_legacy_rgb_light( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features -async def test_no_color_brightness_color_temp_if_no_topics( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for no RGB, brightness, color temp, effector XY.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -281,10 +308,14 @@ async def test_no_color_brightness_color_temp_if_no_topics( "command_topic": "test_light_rgb/set", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_no_color_brightness_color_temp_if_no_topics( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for no RGB, brightness, color temp, effector XY.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -319,13 +350,9 @@ async def test_no_color_brightness_color_temp_if_no_topics( assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the controlling of the state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -342,10 +369,14 @@ async def test_controlling_state_via_topic( "qos": "0", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_controlling_state_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the controlling of the state via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -463,35 +494,22 @@ async def test_controlling_state_via_topic( assert light_state.attributes.get("effect") == "colorloop" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + light.DOMAIN, COLOR_MODES_CONFIG, ({"state_topic": "test_light_rgb"},) + ) + ], +) async def test_controlling_state_via_topic2( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test the controlling of the state via topic for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "white", "xy"] - - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "effect": True, - "name": "test", - "qos": "0", - "schema": "json", - "state_topic": "test_light_rgb", - "supported_color_modes": supported_color_modes, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -641,25 +659,9 @@ async def test_controlling_state_via_topic2( ) -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - fake_state = State( - "light.test", - "on", - { - "brightness": 95, - "hs_color": [100, 100], - "effect": "random", - "color_temp": 100, - }, - ) - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -675,10 +677,26 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": 2, } } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of command in optimistic mode.""" + fake_state = State( + "light.test", + "on", + { + "brightness": 95, + "hs_color": [100, 100], + "effect": "random", + "color_temp": 100, }, ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -788,8 +806,35 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes["xy_color"] == (0.611, 0.375) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "brightness": True, + "color_mode": True, + "command_topic": "test_light_rgb/set", + "effect": True, + "name": "test", + "qos": 2, + "schema": "json", + "supported_color_modes": [ + "color_temp", + "hs", + "rgb", + "rgbw", + "rgbww", + "white", + "xy", + ], + } + } + } + ], +) async def test_sending_mqtt_commands_and_optimistic2( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test the sending of command in optimistic mode for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "white", "xy"] @@ -806,26 +851,7 @@ async def test_sending_mqtt_commands_and_optimistic2( ) mock_restore_cache(hass, (fake_state,)) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - light.DOMAIN: { - "brightness": True, - "color_mode": True, - "command_topic": "test_light_rgb/set", - "effect": True, - "name": "test", - "qos": 2, - "schema": "json", - "supported_color_modes": supported_color_modes, - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -1020,13 +1046,9 @@ async def test_sending_mqtt_commands_and_optimistic2( mqtt_mock.async_publish.reset_mock() -async def test_sending_hs_color( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends hs color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1037,10 +1059,14 @@ async def test_sending_hs_color( "hs": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_hs_color( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends hs color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1083,13 +1109,9 @@ async def test_sending_hs_color( ) -async def test_sending_rgb_color_no_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1099,10 +1121,15 @@ async def test_sending_rgb_color_no_brightness( "rgb": True, } } - }, - ) + } + ], +) +async def test_sending_rgb_color_no_brightness( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1140,14 +1167,9 @@ async def test_sending_rgb_color_no_brightness( ) -async def test_sending_rgb_color_no_brightness2( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - supported_color_modes = ["rgb", "rgbw", "rgbww"] - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1155,13 +1177,17 @@ async def test_sending_rgb_color_no_brightness2( "command_topic": "test_light_rgb/set", "name": "test", "schema": "json", - "supported_color_modes": supported_color_modes, + "supported_color_modes": ["rgb", "rgbw", "rgbww"], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_rgb_color_no_brightness2( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1221,13 +1247,9 @@ async def test_sending_rgb_color_no_brightness2( ) -async def test_sending_rgb_color_with_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1238,10 +1260,14 @@ async def test_sending_rgb_color_with_brightness( "rgb": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_rgb_color_with_brightness( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1289,13 +1315,9 @@ async def test_sending_rgb_color_with_brightness( ) -async def test_sending_rgb_color_with_scaled_brightness( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends rgb color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1307,10 +1329,15 @@ async def test_sending_rgb_color_with_scaled_brightness( "rgb": True, } } - }, - ) + } + ], +) +async def test_sending_rgb_color_with_scaled_brightness( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends rgb color parameters.""" await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1358,13 +1385,9 @@ async def test_sending_rgb_color_with_scaled_brightness( ) -async def test_sending_scaled_white( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with scaled white.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1378,10 +1401,14 @@ async def test_sending_scaled_white( "white_scale": 50, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_scaled_white( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with scaled white.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1405,13 +1432,9 @@ async def test_sending_scaled_white( mqtt_mock.async_publish.reset_mock() -async def test_sending_xy_color( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test light.turn_on with hs color sends xy color parameters.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1422,10 +1445,14 @@ async def test_sending_xy_color( "xy": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_xy_color( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test light.turn_on with hs color sends xy color parameters.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1467,13 +1494,9 @@ async def test_sending_xy_color( ) -async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for effect being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1484,10 +1507,14 @@ async def test_effect( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_effect( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for effect being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1533,13 +1560,9 @@ async def test_effect( assert state.attributes.get("effect") == "colorloop" -async def test_flash_short_and_long( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for flash length being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1551,10 +1574,14 @@ async def test_flash_short_and_long( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_flash_short_and_long( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for flash length being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1598,13 +1625,9 @@ async def test_flash_short_and_long( assert state.state == STATE_OFF -async def test_transition( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for transition time being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1614,10 +1637,14 @@ async def test_transition( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_transition( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for transition time being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1648,13 +1675,9 @@ async def test_transition( assert state.state == STATE_OFF -async def test_brightness_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for brightness scaling.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1666,10 +1689,14 @@ async def test_brightness_scale( "brightness_scale": 99, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_brightness_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for brightness scaling.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1693,13 +1720,9 @@ async def test_brightness_scale( assert state.attributes.get("brightness") == 255 -async def test_white_scale( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for white scaling.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1714,10 +1737,14 @@ async def test_white_scale( "white_scale": 50, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_white_scale( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for white scaling.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1754,13 +1781,9 @@ async def test_white_scale( assert state.attributes.get("brightness") == 128 -async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that invalid color/brightness/etc. values are ignored.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -1774,10 +1797,14 @@ async def test_invalid_values( "qos": "0", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_invalid_values( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that invalid color/brightness/etc. values are ignored.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2203,23 +2230,27 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_max_mireds/set", + "color_temp": True, + "max_mireds": 370, + } + } + } + ], +) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - config = { - light.DOMAIN: { - "schema": "json", - "name": "test", - "command_topic": "test_max_mireds/set", - "color_temp": True, - "max_mireds": 370, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index c8018d4847fb..166687b8595c 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -44,7 +44,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -100,53 +99,60 @@ def light_platform_only(): @pytest.mark.parametrize( - "test_config", + "hass_config", [ - ({"schema": "template", "name": "test"},), + ({mqtt.DOMAIN: {light.DOMAIN: {"schema": "template", "name": "test"}}},), ( { - "schema": "template", - "name": "test", - "command_topic": "test_topic", + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_topic", + } + } }, ), ( { - "schema": "template", - "name": "test", - "command_topic": "test_topic", - "command_on_template": "on", + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_topic", + "command_on_template": "on", + } + } }, ), ( { - "schema": "template", - "name": "test", - "command_topic": "test_topic", - "command_off_template": "off", + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_topic", + "command_off_template": "off", + } + } }, ), ], ) async def test_setup_fails( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, test_config + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that setup fails with missing required configuration items.""" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {light.DOMAIN: test_config}}, - ) - assert "Invalid config for [mqtt]" in caplog.text + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert "Invalid config" in caplog.text -async def test_rgb_light( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test RGB light flags brightness support.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -160,10 +166,14 @@ async def test_rgb_light( "blue_template": '{{ value.split(",")[4].' 'split("-")[2] }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_rgb_light( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test RGB light flags brightness support.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -173,13 +183,9 @@ async def test_rgb_light( assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features -async def test_state_change_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state change via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -197,10 +203,14 @@ async def test_state_change_via_topic( "state_template": '{{ value.split(",")[0] }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_change_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state change via topic.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -228,13 +238,9 @@ async def test_state_change_via_topic( assert state.state == STATE_UNKNOWN -async def test_state_brightness_color_effect_temp_change_via_topic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state, bri, color, effect, color temp change.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -254,16 +260,20 @@ async def test_state_brightness_color_effect_temp_change_via_topic( "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].' 'split("-")[0] }}', - "green_template": '{{ value.split(",")[3].' 'split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].' 'split("-")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', "effect_template": '{{ value.split(",")[4] }}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_state_brightness_color_effect_temp_change_via_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state, bri, color, effect, color temp change.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -339,25 +349,9 @@ async def test_state_brightness_color_effect_temp_change_via_topic( assert light_state.attributes.get("effect") == "rainbow" -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - fake_state = State( - "light.test", - "on", - { - "brightness": 95, - "hs_color": [100, 100], - "effect": "random", - "color_temp": 100, - }, - ) - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -384,10 +378,26 @@ async def test_sending_mqtt_commands_and_optimistic( "qos": 2, } } + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of command in optimistic mode.""" + fake_state = State( + "light.test", + "on", + { + "brightness": 95, + "hs_color": [100, 100], + "effect": "random", + "color_temp": 100, }, ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -481,13 +491,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get("rgb_color") == (0, 255, 127) -async def test_sending_mqtt_commands_non_optimistic_brightness_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending of command in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -508,16 +514,20 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].' 'split("-")[0] }}', - "green_template": '{{ value.split(",")[3].' 'split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].' 'split("-")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', "effect_template": '{{ value.split(",")[4] }}', } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_non_optimistic_brightness_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending of command in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -604,13 +614,9 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( state = hass.states.get("light.test") -async def test_effect( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test effect sent over MQTT in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -623,10 +629,14 @@ async def test_effect( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_effect( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test effect sent over MQTT in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -659,13 +669,9 @@ async def test_effect( assert state.attributes.get("effect") == "colorloop" -async def test_flash( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test flash sent over MQTT in optimistic mode.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -677,10 +683,14 @@ async def test_flash( "qos": 0, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_flash( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test flash sent over MQTT in optimistic mode.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -710,13 +720,9 @@ async def test_flash( assert state.state == STATE_ON -async def test_transition( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test for transition time being sent when included.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -728,10 +734,14 @@ async def test_transition( "qos": 1, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_transition( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test for transition time being sent when included.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -754,13 +764,9 @@ async def test_transition( assert state.state == STATE_OFF -async def test_invalid_values( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that invalid values are ignored.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { light.DOMAIN: { @@ -780,16 +786,21 @@ async def test_invalid_values( "state_template": '{{ value.split(",")[0] }}', "brightness_template": '{{ value.split(",")[1] }}', "color_temp_template": '{{ value.split(",")[2] }}', - "red_template": '{{ value.split(",")[3].' 'split("-")[0] }}', - "green_template": '{{ value.split(",")[3].' 'split("-")[1] }}', - "blue_template": '{{ value.split(",")[3].' 'split("-")[2] }}', + "red_template": '{{ value.split(",")[3].split("-")[0] }}', + "green_template": '{{ value.split(",")[3].split("-")[1] }}', + "blue_template": '{{ value.split(",")[3].split("-")[2] }}', "effect_template": '{{ value.split(",")[4] }}', } } - }, - ) + } + ], +) +async def test_invalid_values( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that invalid values are ignored.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1173,25 +1184,29 @@ async def test_entity_debug_info_message( ) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "template", + "name": "test", + "command_topic": "test_max_mireds/set", + "command_on_template": "on", + "command_off_template": "off", + "color_temp_template": "{{ value }}", + "max_mireds": 370, + } + } + } + ], +) async def test_max_mireds( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test setting min_mireds and max_mireds.""" - config = { - light.DOMAIN: { - "schema": "template", - "name": "test", - "command_topic": "test_max_mireds/set", - "command_on_template": "on", - "command_off_template": "off", - "color_temp_template": "{{ value }}", - "max_mireds": 370, - } - } - - assert await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 From 0570405a3cd81deb59fdfeb296bb5b8c99a5894e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:41:36 +0100 Subject: [PATCH 0734/1058] Prepare MQTT platform tests part6 (#90129) * Tests lock * Tests mixins * Tests number * Tests scene --- tests/components/mqtt/test_lock.py | 407 +++++++++++----------- tests/components/mqtt/test_mixins.py | 37 +- tests/components/mqtt/test_number.py | 490 +++++++++++++++------------ tests/components/mqtt/test_scene.py | 29 +- 4 files changed, 506 insertions(+), 457 deletions(-) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 0c8b6680c55e..f13de4035355 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -25,9 +25,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -63,6 +63,22 @@ DEFAULT_CONFIG = { mqtt.DOMAIN: {lock.DOMAIN: {"name": "test", "command_topic": "test-topic"}} } +CONFIG_WITH_STATES = { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "state_locked": "closed", + "state_locking": "closing", + "state_unlocked": "open", + "state_unlocking": "opening", + } + } +} + @pytest.fixture(autouse=True) def lock_platform_only(): @@ -72,42 +88,22 @@ def lock_platform_only(): @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ("LOCKED", STATE_LOCKED), - ("LOCKING", STATE_LOCKING), - ("UNLOCKED", STATE_UNLOCKED), - ("UNLOCKING", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "closed", STATE_LOCKED), + (CONFIG_WITH_STATES, "closing", STATE_LOCKING), + (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), + (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), ], ) async def test_controlling_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "LOCKED", - "state_locking": "LOCKING", - "state_unlocked": "UNLOCKED", - "state_unlocking": "UNLOCKING", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -115,48 +111,29 @@ async def test_controlling_state_via_topic( assert not state.attributes.get(ATTR_SUPPORTED_FEATURES) async_fire_mqtt_message(hass, "state-topic", payload) + await hass.async_block_till_done() state = hass.states.get("lock.test") assert state.state is lock_state @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ("closed", STATE_LOCKED), - ("closing", STATE_LOCKING), - ("open", STATE_UNLOCKED), - ("opening", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "closed", STATE_LOCKED), + (CONFIG_WITH_STATES, "closing", STATE_LOCKING), + (CONFIG_WITH_STATES, "open", STATE_UNLOCKED), + (CONFIG_WITH_STATES, "opening", STATE_UNLOCKING), ], ) async def test_controlling_non_default_state_via_topic( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "closed", - "state_locking": "closing", - "state_unlocked": "open", - "state_unlocking": "opening", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -169,43 +146,54 @@ async def test_controlling_non_default_state_via_topic( @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ('{"val":"LOCKED"}', STATE_LOCKED), - ('{"val":"LOCKING"}', STATE_LOCKING), - ('{"val":"UNLOCKED"}', STATE_UNLOCKED), - ('{"val":"UNLOCKING"}', STATE_UNLOCKING), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closed"}', + STATE_LOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closing"}', + STATE_LOCKING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"opening"}', + STATE_UNLOCKING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"open"}', + STATE_UNLOCKED, + ), ], ) async def test_controlling_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "LOCKED", - "state_locking": "LOCKING", - "state_unlocked": "UNLOCKED", - "state_unlocking": "UNLOCKING", - "value_template": "{{ value_json.val }}", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -217,43 +205,54 @@ async def test_controlling_state_via_topic_and_json_message( @pytest.mark.parametrize( - ("payload", "lock_state"), + ("hass_config", "payload", "lock_state"), [ - ('{"val":"closed"}', STATE_LOCKED), - ('{"val":"closing"}', STATE_LOCKING), - ('{"val":"open"}', STATE_UNLOCKED), - ('{"val":"opening"}', STATE_UNLOCKING), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closed"}', + STATE_LOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"closing"}', + STATE_LOCKING, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"open"}', + STATE_UNLOCKED, + ), + ( + help_custom_config( + lock.DOMAIN, + CONFIG_WITH_STATES, + ({"value_template": "{{ value_json.val }}"},), + ), + '{"val":"opening"}', + STATE_UNLOCKING, + ), ], ) async def test_controlling_non_default_state_via_topic_and_json_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - payload, - lock_state, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + payload: str, + lock_state: str, ) -> None: """Test the controlling state via topic and JSON message.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "closed", - "state_locking": "closing", - "state_unlocked": "open", - "state_unlocking": "opening", - "value_template": "{{ value_json.val }}", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -264,13 +263,9 @@ async def test_controlling_non_default_state_via_topic_and_json_message( assert state.state is lock_state -async def test_sending_mqtt_commands_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -282,10 +277,14 @@ async def test_sending_mqtt_commands_and_optimistic( "state_unlocked": "UNLOCKED", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_and_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test optimistic mode without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -312,13 +311,9 @@ async def test_sending_mqtt_commands_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_with_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test sending commands with template.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -333,10 +328,14 @@ async def test_sending_mqtt_commands_with_template( "state_unlocked": "UNLOCKED", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_with_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test sending commands with template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -373,30 +372,30 @@ async def test_sending_mqtt_commands_with_template( assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + "optimistic": True, + } + } + } + ], +) async def test_sending_mqtt_commands_and_explicit_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test optimistic mode without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "state_topic": "state-topic", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "state_locked": "LOCKED", - "state_unlocked": "UNLOCKED", - "optimistic": True, - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -423,29 +422,29 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + lock.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_open": "OPEN", + "state_locked": "LOCKED", + "state_unlocked": "UNLOCKED", + } + } + } + ], +) async def test_sending_mqtt_commands_support_open_and_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test open function of the lock without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - lock.DOMAIN: { - "name": "test", - "command_topic": "command-topic", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "payload_open": "OPEN", - "state_locked": "LOCKED", - "state_unlocked": "UNLOCKED", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -483,13 +482,9 @@ async def test_sending_mqtt_commands_support_open_and_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test open function of the lock without state topic.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -504,10 +499,14 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( "optimistic": True, } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test open function of the lock without state topic.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -545,13 +544,9 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_pessimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test function of the lock with state topics.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { lock.DOMAIN: { @@ -568,10 +563,14 @@ async def test_sending_mqtt_commands_pessimistic( "state_jammed": "JAMMED", } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands_pessimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test function of the lock with state topics.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index c9bcd07f26c3..18d59f986759 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -2,28 +2,19 @@ from unittest.mock import patch +import pytest + from homeassistant.components import mqtt, sensor from homeassistant.const import EVENT_STATE_CHANGED, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.setup import async_setup_component from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) -async def test_availability_with_shared_state_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test the state is not changed twice. - - When an entity with a shared state_topic and availability_topic becomes available - The state should only change once. - """ - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -36,10 +27,20 @@ async def test_availability_with_shared_state_topic( "availability_template": "{{ value != '0' }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) +async def test_availability_with_shared_state_topic( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the state is not changed twice. + + When an entity with a shared state_topic and availability_topic becomes available + The state should only change once. + """ + await mqtt_mock_entry_no_yaml_config() events = [] diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index eb2a64022846..bd28d75ac9a6 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -30,7 +30,6 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -76,31 +75,30 @@ def number_platform_only(): yield -async def test_run_number_setup( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "device_class": "temperature", - "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, "payload_reset": "reset!", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_number_setup( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload.""" + await mqtt_mock_entry_no_yaml_config() - async_fire_mqtt_message(hass, topic, "10") + async_fire_mqtt_message(hass, "test/state_number", "10") await hass.async_block_till_done() @@ -109,7 +107,7 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" - async_fire_mqtt_message(hass, topic, "20.5") + async_fire_mqtt_message(hass, "test/state_number", "20.5") await hass.async_block_till_done() @@ -118,7 +116,7 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" - async_fire_mqtt_message(hass, topic, "reset!") + async_fire_mqtt_message(hass, "test/state_number", "reset!") await hass.async_block_till_done() @@ -128,27 +126,27 @@ async def test_run_number_setup( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" -async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + topic = "test/state_number" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, '{"val":10}') @@ -172,11 +170,25 @@ async def test_value_template( assert state.state == "unknown" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, + "name": "Test Number", + } + } + } + ], +) async def test_restore_native_value( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that the stored native_value is restored.""" - topic = "test/number" RESTORE_DATA = { "native_max_value": None, # Ignored by MQTT number @@ -189,30 +201,28 @@ async def test_restore_native_value( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": topic, - "device_class": "temperature", - "unit_of_measurement": UnitOfTemperature.FAHRENHEIT, - "name": "Test Number", - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.state == "37.8" assert state.attributes.get(ATTR_ASSUMED_STATE) +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number", + "name": "Test Number", + } + } + } + ], +) async def test_run_number_service_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode.""" topic = "test/number" @@ -228,20 +238,8 @@ async def test_run_number_service_optimistic( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": topic, - "name": "Test Number", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -287,8 +285,22 @@ async def test_run_number_service_optimistic( assert state.state == "42.1" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number", + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + } + } + ], +) async def test_run_number_service_optimistic_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in optimistic mode and with a command_template.""" topic = "test/number" @@ -304,21 +316,7 @@ async def test_run_number_service_optimistic_with_command_template( mock_restore_cache_with_extra_data( hass, ((State("number.test_number", "abc"), RESTORE_DATA),) ) - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": topic, - "name": "Test Number", - "command_template": '{"number": {{ value }} }', - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -366,28 +364,28 @@ async def test_run_number_service_optimistic_with_command_template( assert state.state == "42.1" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number/set", + "state_topic": "test/number", + "name": "Test Number", + } + } + } + ], +) async def test_run_number_service( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/number/set" state_topic = "test/number" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, - "name": "Test Number", - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -404,29 +402,29 @@ async def test_run_number_service( assert state.state == "32" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + number.DOMAIN: { + "command_topic": "test/number/set", + "state_topic": "test/number", + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + } + } + ], +) async def test_run_number_service_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode and with a command_template.""" cmd_topic = "test/number/set" state_topic = "test/number" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, - "name": "Test Number", - "command_template": '{"number": {{ value }} }', - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -732,29 +730,28 @@ async def test_entity_debug_info_message( ) -async def test_min_max_step_attributes( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test min/max/step attributes.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "min": 5, "max": 110, "step": 20, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_min_max_step_attributes( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test min/max/step attributes.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MIN) == 5 @@ -762,129 +759,180 @@ async def test_min_max_step_attributes( assert state.attributes.get(ATTR_STEP) == 20 -async def test_invalid_min_max_attributes( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test invalid min/max attributes.""" - topic = "test/number" - assert not await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "min": 35, "max": 10, } } - }, - ) - + } + ], +) +async def test_invalid_min_max_attributes( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid min/max attributes.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text -async def test_default_mode( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test default mode.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_default_mode( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test default mode.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MODE) == "auto" -@pytest.mark.parametrize("mode", ("auto", "box", "slider")) +@pytest.mark.parametrize( + ("hass_config", "mode"), + [ + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "auto", + } + } + }, + "auto", + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "box", + } + } + }, + "box", + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "slider", + } + } + }, + "slider", + ), + ], +) async def test_mode( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, mode, ) -> None: """Test mode.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, - "name": "Test Number", - "mode": mode, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MODE) == mode -@pytest.mark.parametrize(("mode", "valid"), [("bleh", False), ("auto", True)]) -async def test_invalid_mode(hass: HomeAssistant, mode, valid) -> None: - """Test invalid mode.""" - topic = "test/number" - assert ( - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + ("hass_config", "valid"), + [ + ( { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", - "mode": mode, + "mode": "bleh", } } }, - ) - is valid - ) - - -async def test_mqtt_payload_not_a_number_warning( + False, + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "mode": "auto", + } + } + }, + True, + ), + ], +) +async def test_invalid_mode( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + valid: bool, ) -> None: - """Test warning for MQTT payload which is not a number.""" - topic = "test/number" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test invalid mode.""" + if valid: + await mqtt_mock_entry_no_yaml_config() + return + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_mqtt_payload_not_a_number_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test warning for MQTT payload which is not a number.""" + topic = "test/state_number" + + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, "not_a_number") @@ -893,30 +941,32 @@ async def test_mqtt_payload_not_a_number_warning( assert "Payload 'not_a_number' is not a Number" in caplog.text -async def test_mqtt_payload_out_of_range_error( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test error when MQTT payload is out of min/max range.""" - topic = "test/number" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { number.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", "name": "Test Number", "min": 5, "max": 110, } } - }, - ) + } + ], +) +async def test_mqtt_payload_out_of_range_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test error when MQTT payload is out of min/max range.""" + topic = "test/state_number" + await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, "115.5") diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 3da5fd4f36a7..56350c90c0dd 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -7,7 +7,6 @@ import pytest from homeassistant.components import mqtt, scene from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component from .test_common import ( help_test_availability_when_connection_lost, @@ -44,16 +43,9 @@ def scene_platform_only(): yield -async def test_sending_mqtt_commands( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the sending MQTT commands.""" - fake_state = State("scene.test", STATE_UNKNOWN) - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { scene.DOMAIN: { @@ -62,10 +54,17 @@ async def test_sending_mqtt_commands( "payload_on": "beer on", }, } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_sending_mqtt_commands( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands.""" + fake_state = State("scene.test", STATE_UNKNOWN) + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("scene.test") assert state.state == STATE_UNKNOWN From f2b4c95a04e38f53febf4793790b2e8d348f8bf5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:42:00 +0100 Subject: [PATCH 0735/1058] Prepare MQTT platform tests part7 (#90130) * Tests select * Tests sensor * Deduplicate test code --- tests/components/mqtt/test_select.py | 279 +++++------ tests/components/mqtt/test_sensor.py | 677 +++++++++++++++------------ 2 files changed, 521 insertions(+), 435 deletions(-) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 3a639ecf08f8..bbda9c88deb0 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -1,4 +1,5 @@ """The tests for mqtt select component.""" +from collections.abc import Generator import copy import json from typing import Any @@ -21,7 +22,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, State -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType from .test_common import ( help_test_availability_when_connection_lost, @@ -74,27 +75,35 @@ def select_platform_only(): yield -async def test_run_select_setup( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, +def _test_run_select_setup_params( + topic: str, +) -> Generator[tuple[ConfigType, str], None]: + yield ( { mqtt.DOMAIN: { select.DOMAIN: { "state_topic": topic, - "command_topic": topic, + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], } } }, + topic, ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + + +@pytest.mark.parametrize( + ("hass_config", "topic"), + _test_run_select_setup_params("test/select_stat"), +) +async def test_run_select_setup( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + topic: str, +) -> None: + """Test that it fetches the given payload.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, "milk") @@ -111,44 +120,43 @@ async def test_run_select_setup( assert state.state == "beer" -async def test_value_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that it fetches the given payload with a template.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/select_stat", + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that it fetches the given payload with a template.""" + await mqtt_mock_entry_no_yaml_config() - async_fire_mqtt_message(hass, topic, '{"val":"milk"}') + async_fire_mqtt_message(hass, "test/select_stat", '{"val":"milk"}') await hass.async_block_till_done() state = hass.states.get("select.test_select") assert state.state == "milk" - async_fire_mqtt_message(hass, topic, '{"val":"beer"}') + async_fire_mqtt_message(hass, "test/select_stat", '{"val":"beer"}') await hass.async_block_till_done() state = hass.states.get("select.test_select") assert state.state == "beer" - async_fire_mqtt_message(hass, topic, '{"val": null}') + async_fire_mqtt_message(hass, "test/select_stat", '{"val": null}') await hass.async_block_till_done() @@ -156,30 +164,28 @@ async def test_value_template( assert state.state == STATE_UNKNOWN -async def test_run_select_service_optimistic( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that set_value service works in optimistic mode.""" - topic = "test/select" - - fake_state = State("select.test_select", "milk") - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "command_topic": topic, + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_select_service_optimistic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that set_value service works in optimistic mode.""" + fake_state = State("select.test_select", "milk") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -192,37 +198,35 @@ async def test_run_select_service_optimistic( blocking=True, ) - mqtt_mock.async_publish.assert_called_once_with(topic, "beer", 0, False) + mqtt_mock.async_publish.assert_called_once_with("test/select_cmd", "beer", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("select.test_select") assert state.state == "beer" -async def test_run_select_service_optimistic_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that set_value service works in optimistic mode and with a command_template.""" - topic = "test/select" - - fake_state = State("select.test_select", "milk") - mock_restore_cache(hass, (fake_state,)) - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "command_topic": topic, + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], "command_template": '{"option": "{{ value }}"}', } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_select_service_optimistic_with_command_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that set_value service works in optimistic mode and with a command_template.""" + fake_state = State("select.test_select", "milk") + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -236,36 +240,36 @@ async def test_run_select_service_optimistic_with_command_template( ) mqtt_mock.async_publish.assert_called_once_with( - topic, '{"option": "beer"}', 0, False + "test/select_cmd", '{"option": "beer"}', 0, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("select.test_select") assert state.state == "beer" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + select.DOMAIN: { + "command_topic": "test/select/set", + "state_topic": "test/select", + "name": "Test Select", + "options": ["milk", "beer"], + } + } + } + ], +) async def test_run_select_service( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator ) -> None: """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/select/set" state_topic = "test/select" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - select.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, - "name": "Test Select", - "options": ["milk", "beer"], - } - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -282,30 +286,30 @@ async def test_run_select_service( assert state.state == "beer" -async def test_run_select_service_with_command_template( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test that set_value service works in non optimistic mode and with a command_template.""" - cmd_topic = "test/select/set" - state_topic = "test/select" - - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "command_topic": cmd_topic, - "state_topic": state_topic, + "command_topic": "test/select/set", + "state_topic": "test/select", "name": "Test Select", "options": ["milk", "beer"], "command_template": '{"option": "{{ value }}"}', } } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_run_select_service_with_command_template( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test that set_value service works in non optimistic mode and with a command_template.""" + cmd_topic = "test/select/set" + state_topic = "test/select" + + mqtt_mock = await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -609,60 +613,65 @@ async def test_entity_debug_info_message( ) -@pytest.mark.parametrize("options", [["milk", "beer"], ["milk"], []]) +def _test_options_attributes_options_config( + request: tuple[list[str]], +) -> Generator[tuple[ConfigType, list[str]], None]: + for option in request: + yield ( + { + mqtt.DOMAIN: { + select.DOMAIN: { + "command_topic": "test/select/set", + "state_topic": "test/select", + "name": "Test select", + "options": option, + } + } + }, + option, + ) + + +@pytest.mark.parametrize( + ("hass_config", "options"), + _test_options_attributes_options_config((["milk", "beer"], ["milk"], [])), +) async def test_options_attributes( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - options, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + options: list[str], ) -> None: """Test options attribute.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - select.DOMAIN: { - "state_topic": topic, - "command_topic": topic, - "name": "Test select", - "options": options, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("select.test_select") assert state.attributes.get(ATTR_OPTIONS) == options -async def test_mqtt_payload_not_an_option_warning( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test warning for MQTT payload which is not a valid option.""" - topic = "test/select" - await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { select.DOMAIN: { - "state_topic": topic, - "command_topic": topic, + "state_topic": "test/select_stat", + "command_topic": "test/select_cmd", "name": "Test Select", "options": ["milk", "beer"], } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_mqtt_payload_not_an_option_warning( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test warning for MQTT payload which is not a valid option.""" + await mqtt_mock_entry_no_yaml_config() - async_fire_mqtt_message(hass, topic, "öl") + async_fire_mqtt_message(hass, "test/select_stat", "öl") await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 6889069c8ca2..01c897a9d866 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -18,12 +18,13 @@ from homeassistant.const import ( Platform, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -82,13 +83,9 @@ def sensor_platform_only(): yield -async def test_setting_sensor_value_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -98,10 +95,14 @@ async def test_setting_sensor_value_via_mqtt_message( "suggested_display_precision": 1, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", "100.22") state = hass.states.get("sensor.test") @@ -113,64 +114,118 @@ async def test_setting_sensor_value_via_mqtt_message( @pytest.mark.parametrize( - ("device_class", "native_value", "state_value", "log"), + ("hass_config", "device_class", "native_value", "state_value", "log"), [ - (sensor.SensorDeviceClass.DATE, "2021-11-18", "2021-11-18", False), - (sensor.SensorDeviceClass.DATE, "invalid", STATE_UNKNOWN, True), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.DATE},), + ), + sensor.SensorDeviceClass.DATE, + "2021-11-18", + "2021-11-18", + False, + ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.DATE},), + ), + sensor.SensorDeviceClass.DATE, + "invalid", + STATE_UNKNOWN, + True, + ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18T20:25:00+00:00", "2021-11-18T20:25:00+00:00", False, ), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18 20:25:00+00:00", "2021-11-18T20:25:00+00:00", False, ), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-11-18 20:25:00+01:00", "2021-11-18T19:25:00+00:00", False, ), ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), sensor.SensorDeviceClass.TIMESTAMP, "2021-13-18T35:25:00+00:00", STATE_UNKNOWN, True, ), - (sensor.SensorDeviceClass.TIMESTAMP, "invalid", STATE_UNKNOWN, True), - (sensor.SensorDeviceClass.ENUM, "some_value", "some_value", False), - (None, "some_value", "some_value", False), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.TIMESTAMP},), + ), + sensor.SensorDeviceClass.TIMESTAMP, + "invalid", + STATE_UNKNOWN, + True, + ), + ( + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ({"device_class": sensor.SensorDeviceClass.ENUM},), + ), + sensor.SensorDeviceClass.ENUM, + "some_value", + "some_value", + False, + ), + ( + help_custom_config( + sensor.DOMAIN, DEFAULT_CONFIG, ({"device_class": None},) + ), + None, + "some_value", + "some_value", + False, + ), ], ) async def test_setting_sensor_native_value_handling_via_mqtt_message( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - device_class, - native_value, - state_value, - log, + device_class: sensor.SensorDeviceClass | None, + native_value: str, + state_value: str, + log: bool, ) -> None: """Test the setting of the value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_topic": "test-topic", - "device_class": device_class, - } - } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", native_value) state = hass.states.get("sensor.test") @@ -180,14 +235,9 @@ async def test_setting_sensor_native_value_handling_via_mqtt_message( assert log == ("Invalid state message" in caplog.text) -async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test the setting of a numeric sensor value via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -198,10 +248,16 @@ async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( "unit_of_measurement": "W", } } - }, - ) + } + ], +) +async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of a numeric sensor value via MQTT.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() # float value async_fire_mqtt_message(hass, "test-topic", '{ "power": 45.3, "current": 5.24 }') @@ -235,15 +291,9 @@ async def test_setting_numeric_sensor_native_value_handling_via_mqtt_message( assert state.state == "21" -async def test_setting_sensor_value_expires_availability_topic( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -254,10 +304,14 @@ async def test_setting_sensor_value_expires_availability_topic( "availability_topic": "availability-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires_availability_topic( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE @@ -268,18 +322,12 @@ async def test_setting_sensor_value_expires_availability_topic( state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, caplog) + await expires_helper(hass) -async def test_setting_sensor_value_expires( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the expiration of the value.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -290,19 +338,23 @@ async def test_setting_sensor_value_expires( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_expires( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the expiration of the value.""" + await mqtt_mock_entry_no_yaml_config() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, caplog) + await expires_helper(hass) -async def expires_helper(hass: HomeAssistant, caplog) -> None: +async def expires_helper(hass: HomeAssistant) -> None: """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) @@ -353,13 +405,9 @@ async def expires_helper(hass: HomeAssistant, caplog) -> None: assert state.state == STATE_UNAVAILABLE -async def test_setting_sensor_value_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -368,10 +416,14 @@ async def test_setting_sensor_value_via_mqtt_json_message( "value_template": "{{ value_json.val }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT with JSON payload.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", '{ "val": "100" }') state = hass.states.get("sensor.test") @@ -385,13 +437,9 @@ async def test_setting_sensor_value_via_mqtt_json_message( assert state.state == "" -async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT with fall back to current state.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -400,10 +448,14 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st "value_template": "{{ value_json.val | is_defined }}-{{ value_json.par }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT with fall back to current state.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "test-topic", '{ "val": "valcontent", "par": "parcontent" }' @@ -418,15 +470,9 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st assert state.state == "valcontent-parcontent" -async def test_setting_sensor_last_reset_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -437,10 +483,16 @@ async def test_setting_sensor_last_reset_via_mqtt_message( "last_reset_topic": "last-reset-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_last_reset_via_mqtt_message( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the last_reset property via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") state = hass.states.get("sensor.test") @@ -452,17 +504,9 @@ async def test_setting_sensor_last_reset_via_mqtt_message( ) -@pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) -async def test_setting_sensor_bad_last_reset_via_mqtt_message( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - datestring, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -473,10 +517,18 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( "last_reset_topic": "last-reset-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +@pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) +async def test_setting_sensor_bad_last_reset_via_mqtt_message( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + datestring, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, +) -> None: + """Test the setting of the last_reset property via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", datestring) state = hass.states.get("sensor.test") @@ -484,13 +536,9 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( assert "Invalid last_reset message" in caplog.text -async def test_setting_sensor_empty_last_reset_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the last_reset property via MQTT.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -501,23 +549,23 @@ async def test_setting_sensor_empty_last_reset_via_mqtt_message( "last_reset_topic": "last-reset-topic", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_empty_last_reset_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the last_reset property via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", "") state = hass.states.get("sensor.test") assert state.attributes.get("last_reset") is None -async def test_setting_sensor_last_reset_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the setting of the value via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -529,10 +577,14 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message( "last_reset_value_template": "{{ value_json.last_reset }}", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_setting_sensor_last_reset_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the setting of the value via MQTT with JSON payload.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "last-reset-topic", '{ "last_reset": "2020-01-02 08:11:00" }' @@ -541,35 +593,44 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message( assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" -@pytest.mark.parametrize("extra", [{}, {"last_reset_topic": "test-topic"}]) -async def test_setting_sensor_last_reset_via_mqtt_json_message_2( - hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - extra, -) -> None: - """Test the setting of the value via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { - **{ - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "kWh", - "value_template": "{{ value_json.value | float / 60000 }}", - "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", - }, - **extra, - } + "name": "test", + "state_class": "total", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + }, } }, - ) + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_class": "total", + "state_topic": "test-topic", + "unit_of_measurement": "kWh", + "value_template": "{{ value_json.value | float / 60000 }}", + "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", + "last_reset_topic": "test-topic", + }, + } + }, + ], +) +async def test_setting_sensor_last_reset_via_mqtt_json_message_2( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT with JSON payload.""" await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, @@ -586,13 +647,9 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message_2( ) -async def test_force_update_disabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -601,15 +658,19 @@ async def test_force_update_disabled( "unit_of_measurement": "fav unit", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_disabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() - events = [] + events: list[Event] = [] @callback - def test_callback(event) -> None: + def test_callback(event: Event) -> None: events.append(event) hass.bus.async_listen(EVENT_STATE_CHANGED, test_callback) @@ -623,13 +684,9 @@ async def test_force_update_disabled( assert len(events) == 1 -async def test_force_update_enabled( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test force update option.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -639,15 +696,19 @@ async def test_force_update_enabled( "force_update": True, } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_force_update_enabled( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test force update option.""" + await mqtt_mock_entry_no_yaml_config() - events = [] + events: list[Event] = [] @callback - def test_callback(event) -> None: + def test_callback(event: Event) -> None: events.append(event) hass.bus.async_listen(EVENT_STATE_CHANGED, test_callback) @@ -747,13 +808,9 @@ async def test_discovery_update_availability( ) -async def test_invalid_device_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test device_class option with invalid value.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -762,22 +819,25 @@ async def test_invalid_device_class( "device_class": "foobarnotreal", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() - - state = hass.states.get("sensor.test") - assert state is None - - -async def test_valid_device_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test device_class option with valid values.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test device_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert ( + "Invalid config for [mqtt]: expected SensorDeviceClass or one of" in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: [ @@ -794,10 +854,14 @@ async def test_valid_device_class( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test device_class option with valid values.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test_1") assert state.attributes["device_class"] == "temperature" @@ -807,13 +871,9 @@ async def test_valid_device_class( assert "device_class" not in state.attributes -async def test_invalid_state_class( - hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test state_class option with invalid value.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -822,22 +882,25 @@ async def test_invalid_state_class( "state_class": "foobarnotreal", } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_no_yaml_config() - - state = hass.states.get("sensor.test") - assert state is None - - -async def test_valid_state_class( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator + } + ], +) +async def test_invalid_state_class( + hass: HomeAssistant, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test state_class option with valid values.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, + """Test state_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry_no_yaml_config() + assert ( + "Invalid config for [mqtt]: expected SensorStateClass or one of" in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: [ @@ -854,10 +917,14 @@ async def test_valid_state_class( }, ] } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_valid_state_class( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test state_class option with valid values.""" + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test_1") assert state.attributes["state_class"] == "measurement" @@ -1237,13 +1304,9 @@ async def test_entity_category( ) -async def test_value_template_with_entity_id( - hass: HomeAssistant, mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator -) -> None: - """Test the access to attributes in value_template via the entity_id.""" - assert await async_setup_component( - hass, - mqtt.DOMAIN, +@pytest.mark.parametrize( + "hass_config", + [ { mqtt.DOMAIN: { sensor.DOMAIN: { @@ -1258,10 +1321,14 @@ async def test_value_template_with_entity_id( {% endif %}', } } - }, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + } + ], +) +async def test_value_template_with_entity_id( + hass: HomeAssistant, mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator +) -> None: + """Test the access to attributes in value_template via the entity_id.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic", "100") state = hass.states.get("sensor.test") @@ -1279,38 +1346,43 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "name": "test1", + "expire_after": 30, + "state_topic": "test-topic1", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.FAHRENHEIT.value, + }, + { + "name": "test2", + "expire_after": 5, + "state_topic": "test-topic2", + "device_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS.value, + }, + ), + ) + ], +) async def test_cleanup_triggers_and_restoring_state( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, tmp_path: Path, freezer: FrozenDateTimeFactory, + hass_config: ConfigType, ) -> None: """Test cleanup old triggers at reloading and restoring the state.""" - domain = sensor.DOMAIN - config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config1["name"] = "test1" - config1["expire_after"] = 30 - config1["state_topic"] = "test-topic1" - config1["device_class"] = "temperature" - config1["unit_of_measurement"] = UnitOfTemperature.FAHRENHEIT.value - - config2 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config2["name"] = "test2" - config2["expire_after"] = 5 - config2["state_topic"] = "test-topic2" - config2["device_class"] = "temperature" - config2["unit_of_measurement"] = UnitOfTemperature.CELSIUS.value - freezer.move_to("2022-02-02 12:01:00+01:00") - assert await async_setup_component( - hass, - mqtt.DOMAIN, - {mqtt.DOMAIN: {domain: [config1, config2]}}, - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, "test-topic1", "100") state = hass.states.get("sensor.test1") assert state.state == "38" # 100 °F -> 38 °C @@ -1321,9 +1393,7 @@ async def test_cleanup_triggers_and_restoring_state( freezer.move_to("2022-02-02 12:01:10+01:00") - await help_test_reload_with_config( - hass, caplog, tmp_path, {mqtt.DOMAIN: {domain: [config1, config2]}} - ) + await help_test_reload_with_config(hass, caplog, tmp_path, hass_config) await hass.async_block_till_done() state = hass.states.get("sensor.test1") @@ -1341,19 +1411,30 @@ async def test_cleanup_triggers_and_restoring_state( assert state.state == "201" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "name": "test3", + "expire_after": 10, + "state_topic": "test-topic3", + }, + ), + ) + ], +) async def test_skip_restoring_state_with_over_due_expire_trigger( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring a state with over due expire timer.""" freezer.move_to("2022-02-02 12:02:00+01:00") - domain = sensor.DOMAIN - config3 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][domain]) - config3["name"] = "test3" - config3["expire_after"] = 10 - config3["state_topic"] = "test-topic3" fake_state = State( "sensor.test3", "300", @@ -1363,11 +1444,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( fake_extra_data = MagicMock() mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - assert await async_setup_component( - hass, mqtt.DOMAIN, {mqtt.DOMAIN: {domain: config3}} - ) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test3") assert state.state == STATE_UNAVAILABLE From 1224b1aff607957b3fd1baae2c36adae0667069c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 08:42:23 +0100 Subject: [PATCH 0736/1058] Use helper on tests MQTT fan platform (#90196) Use helper on tests fan --- tests/components/mqtt/test_fan.py | 118 ++++++++++++++++-------------- 1 file changed, 62 insertions(+), 56 deletions(-) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 9882b9102e14..3e3f6219b0dd 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -34,6 +34,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .test_common import ( + help_custom_config, help_test_availability_when_connection_lost, help_test_availability_without_topic, help_test_custom_availability_payload, @@ -226,36 +227,37 @@ async def test_controlling_state_via_topic( @pytest.mark.parametrize( "hass_config", [ - { - mqtt.DOMAIN: { - fan.DOMAIN: [ - { - "name": "test1", + help_custom_config( + fan.DOMAIN, + { + mqtt.DOMAIN: { + fan.DOMAIN: { "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic1", - "percentage_command_topic": "percentage-command-topic1", - "speed_range_min": 1, - "speed_range_max": 100, - }, - { - "name": "test2", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic2", - "percentage_command_topic": "percentage-command-topic2", - "speed_range_min": 1, - "speed_range_max": 200, - }, - { - "name": "test3", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic3", - "percentage_command_topic": "percentage-command-topic3", - "speed_range_min": 81, - "speed_range_max": 1023, - }, - ] - } - } + "percentage_command_topic": "percentage-command-topic", + } + } + }, + ( + { + "name": "test1", + "percentage_state_topic": "percentage-state-topic1", + "speed_range_min": 1, + "speed_range_max": 100, + }, + { + "name": "test2", + "percentage_state_topic": "percentage-state-topic2", + "speed_range_min": 1, + "speed_range_max": 200, + }, + { + "name": "test3", + "percentage_state_topic": "percentage-state-topic3", + "speed_range_min": 81, + "speed_range_max": 1023, + }, + ), + ), ], ) async def test_controlling_state_via_topic_with_different_speed_range( @@ -672,36 +674,40 @@ async def test_sending_mqtt_commands_and_optimistic( @pytest.mark.parametrize( "hass_config", [ - { - mqtt.DOMAIN: { - fan.DOMAIN: [ - { + help_custom_config( + fan.DOMAIN, + { + mqtt.DOMAIN: { + fan.DOMAIN: { "name": "test1", "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic1", - "percentage_command_topic": "percentage-command-topic1", + "percentage_state_topic": "percentage-state-topic", "speed_range_min": 1, "speed_range_max": 3, - }, - { - "name": "test2", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic2", - "percentage_command_topic": "percentage-command-topic2", - "speed_range_min": 1, - "speed_range_max": 200, - }, - { - "name": "test3", - "command_topic": "command-topic", - "percentage_state_topic": "percentage-state-topic3", - "percentage_command_topic": "percentage-command-topic3", - "speed_range_min": 81, - "speed_range_max": 1023, - }, - ] - } - } + } + } + }, + ( + { + "name": "test1", + "percentage_command_topic": "percentage-command-topic1", + "speed_range_min": 1, + "speed_range_max": 3, + }, + { + "name": "test2", + "percentage_command_topic": "percentage-command-topic2", + "speed_range_min": 1, + "speed_range_max": 200, + }, + { + "name": "test3", + "percentage_command_topic": "percentage-command-topic3", + "speed_range_min": 81, + "speed_range_max": 1023, + }, + ), + ), ], ) async def test_sending_mqtt_commands_with_alternate_speed_range( @@ -1460,7 +1466,7 @@ async def test_attributes( } }, True, - 0, + fan.FanEntityFeature(0), ), ( "test2", From 34324c98de241b0abc2bbba5badcd034917145cc Mon Sep 17 00:00:00 2001 From: Nalin Mahajan Date: Fri, 24 Mar 2023 02:44:35 -0500 Subject: [PATCH 0737/1058] Rename create_api_object to be private (#90187) --- homeassistant/components/control4/light.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 2c92010901bc..574866411963 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -175,7 +175,7 @@ class Control4Light(Control4Entity, LightEntity): self._attr_color_mode = ColorMode.ONOFF self._attr_supported_color_modes = {ColorMode.ONOFF} - def create_api_object(self): + def _create_api_object(self): """Create a pyControl4 device object. This exists so the director token used is always the latest one, without needing to re-init the entire entity. @@ -203,7 +203,7 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - c4_light = self.create_api_object() + c4_light = self._create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 @@ -226,7 +226,7 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - c4_light = self.create_api_object() + c4_light = self._create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 From 7364e6ecb3194f44482ea1b34fe14766943c1f75 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 24 Mar 2023 09:38:43 +0100 Subject: [PATCH 0738/1058] Remove incorrect parametrize decorator (#90219) --- tests/components/mqtt/test_cover.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index dd28d0919bd8..3fb008b2181d 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -2235,7 +2235,6 @@ async def test_tilt_position_altered_range( ) -@pytest.mark.parametrize("hass_config", []) async def test_find_percentage_in_range_defaults(hass: HomeAssistant) -> None: """Test find percentage in range with default range.""" mqtt_cover = MqttCover( From ee74e21541286cf1a29fc505de335b244d5924b1 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 24 Mar 2023 10:06:09 +0100 Subject: [PATCH 0739/1058] Rework UniFi wireless client "wired bug" logic (#89757) --- homeassistant/components/unifi/__init__.py | 42 +++++++++++++++---- homeassistant/components/unifi/controller.py | 34 +-------------- .../components/unifi/device_tracker.py | 2 +- homeassistant/components/unifi/sensor.py | 12 +++--- tests/components/unifi/test_controller.py | 41 +----------------- tests/components/unifi/test_init.py | 12 ++---- 6 files changed, 47 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index adaa7c977f78..d6405d117168 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -1,5 +1,7 @@ """Integration to UniFi Network and its various features.""" +from aiounifi.models.client import Client + from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback @@ -91,33 +93,55 @@ def async_remove_poe_client_entities( class UnifiWirelessClients: """Class to store clients known to be wireless. - This is needed since wireless devices going offline might get marked as wired by UniFi. + This is needed since wireless devices going offline + might get marked as wired by UniFi. """ def __init__(self, hass: HomeAssistant) -> None: """Set up client storage.""" self.hass = hass - self.data: dict[str, dict[str, list[str]]] = {} + self.data: dict[str, dict[str, list[str]] | list[str]] = {} + self.wireless_clients: set[str] = set() self._store: Store = Store(hass, STORAGE_VERSION, STORAGE_KEY) async def async_load(self) -> None: """Load data from file.""" if (data := await self._store.async_load()) is not None: self.data = data + if "wireless_clients" not in data: + data["wireless_clients"] = [ + obj_id + for config_entry in data + for obj_id in data[config_entry]["wireless_devices"] + ] + self.wireless_clients.update(data["wireless_clients"]) @callback - def get_data(self, config_entry: ConfigEntry) -> set[str]: - """Get data related to a specific controller.""" - data = self.data.get(config_entry.entry_id, {"wireless_devices": []}) - return set(data["wireless_devices"]) + def is_wireless(self, client: Client) -> bool: + """Is client known to be wireless. + + Store if client is wireless and not known. + """ + if not client.is_wired and client.mac not in self.wireless_clients: + self.wireless_clients.add(client.mac) + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + return client.mac in self.wireless_clients @callback - def update_data(self, data: set[str], config_entry: ConfigEntry) -> None: + def update_clients(self, clients: set[Client]) -> None: """Update data and schedule to save to file.""" - self.data[config_entry.entry_id] = {"wireless_devices": list(data)} + self.wireless_clients.update( + {client.mac for client in clients if not client.is_wired} + ) self._store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict[str, dict[str, list[str]]]: + def _data_to_save(self) -> dict[str, dict[str, list[str]] | list[str]]: """Return data of UniFi wireless clients to store in a file.""" + self.data["wireless_clients"] = list(self.wireless_clients) return self.data + + def __contains__(self, obj_id: int | str) -> bool: + """Validate membership of item ID.""" + return obj_id in self.wireless_clients diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 8a047606c67c..a5f3c4d77204 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,8 +10,6 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.interfaces.messages import DATA_EVENT -from aiounifi.models.event import EventKey from aiounifi.websocket import WebsocketSignal, WebsocketState import async_timeout @@ -86,8 +84,7 @@ class UniFiController: api.callback = self.async_unifi_signalling_callback self.available = True - self.progress = None - self.wireless_clients = None + self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] self.site_id: str = "" self._site_name = None @@ -247,15 +244,6 @@ class UniFiController: else: LOGGER.info("Connected to UniFi Network") - elif signal == WebsocketSignal.DATA and DATA_EVENT in data: - for event in data[DATA_EVENT]: - if event.key in ( - EventKey.WIRELESS_CLIENT_CONNECTED, - EventKey.WIRELESS_GUEST_CONNECTED, - ): - self.update_wireless_clients() - break - @property def signal_reachable(self) -> str: """Integration specific event to signal a change in connection status.""" @@ -271,22 +259,6 @@ class UniFiController: """Event specific per UniFi device tracker to signal new heartbeat missed.""" return "unifi-heartbeat-missed" - def update_wireless_clients(self): - """Update set of known to be wireless clients.""" - new_wireless_clients = set() - - for client_id in self.api.clients: - if ( - client_id not in self.wireless_clients - and not self.api.clients[client_id].is_wired - ): - new_wireless_clients.add(client_id) - - if new_wireless_clients: - self.wireless_clients |= new_wireless_clients - unifi_wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] - unifi_wireless_clients.update_data(self.wireless_clients, self.config_entry) - async def initialize(self): """Set up a UniFi Network instance.""" await self.api.initialize() @@ -326,9 +298,7 @@ class UniFiController: client.mac, ) - wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS] - self.wireless_clients = wireless_clients.get_data(self.config_entry) - self.update_wireless_clients() + self.wireless_clients.update_clients(set(self.api.clients.values())) self.config_entry.add_update_listener(self.async_config_entry_updated) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index a5b153d7f361..f891416c6358 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -105,7 +105,7 @@ def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bo """Check if device object is disabled.""" client = controller.api.clients[obj_id] - if client.is_wired != (obj_id not in controller.wireless_clients): + if controller.wireless_clients.is_wireless(client) and client.is_wired: if not controller.option_ignore_wired_bug: return False # Wired bug in action diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 05598589febd..420fc3803c3d 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -45,17 +45,17 @@ from .entity import ( @callback def async_client_rx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate receiving data transfer value.""" - if client.mac not in controller.wireless_clients: - return client.wired_rx_bytes_r / 1000000 - return client.rx_bytes_r / 1000000 + if controller.wireless_clients.is_wireless(client): + return client.rx_bytes_r / 1000000 + return client.wired_rx_bytes_r / 1000000 @callback def async_client_tx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate transmission data transfer value.""" - if client.mac not in controller.wireless_clients: - return client.wired_tx_bytes_r / 1000000 - return client.tx_bytes_r / 1000000 + if controller.wireless_clients.is_wireless(client): + return client.tx_bytes_r / 1000000 + return client.wired_tx_bytes_r / 1000000 @callback diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 931c0fccdf0c..e3efaef915b1 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -6,8 +6,6 @@ from http import HTTPStatus from unittest.mock import Mock, patch import aiounifi -from aiounifi.models.event import EventKey -from aiounifi.models.message import MessageKey from aiounifi.websocket import WebsocketState import pytest @@ -182,8 +180,8 @@ async def setup_unifi_integration( config_entry.add_to_hass(hass) if known_wireless_clients: - hass.data[UNIFI_WIRELESS_CLIENTS].update_data( - known_wireless_clients, config_entry + hass.data[UNIFI_WIRELESS_CLIENTS].wireless_clients.update( + known_wireless_clients ) if aioclient_mock: @@ -383,41 +381,6 @@ async def test_connection_state_signalling( assert hass.states.get("device_tracker.client").state == "home" -async def test_wireless_client_event_calls_update_wireless_devices( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket -) -> None: - """Call update_wireless_devices method when receiving wireless client event.""" - client_1_dict = { - "essid": "ssid", - "disabled": False, - "hostname": "client_1", - "ip": "10.0.0.4", - "is_wired": False, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - } - await setup_unifi_integration( - hass, - aioclient_mock, - clients_response=[client_1_dict], - known_wireless_clients=(client_1_dict["mac"],), - ) - - with patch( - "homeassistant.components.unifi.controller.UniFiController.update_wireless_clients", - return_value=None, - ) as wireless_clients_mock: - event = { - "datetime": "2020-01-20T19:37:04Z", - "user": "00:00:00:00:00:01", - "key": EventKey.WIRELESS_CLIENT_CONNECTED.value, - "msg": "User[11:22:33:44:55:66] has connected to WLAN", - "time": 1579549024893, - } - mock_unifi_websocket(message=MessageKey.EVENT, data=event) - assert wireless_clients_mock.assert_called_once - - async def test_reconnect_mechanism( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket ) -> None: diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index cb232445eb1e..cce26ac84cc7 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -89,19 +89,13 @@ async def test_wireless_clients( "is_wired": False, "mac": "00:00:00:00:00:02", } - config_entry = await setup_unifi_integration( + await setup_unifi_integration( hass, aioclient_mock, clients_response=[client_1, client_2] ) await flush_store(hass.data[unifi.UNIFI_WIRELESS_CLIENTS]._store) - for mac in [ + assert sorted(hass_storage[unifi.STORAGE_KEY]["data"]["wireless_clients"]) == [ "00:00:00:00:00:00", "00:00:00:00:00:01", "00:00:00:00:00:02", - ]: - assert ( - mac - in hass_storage[unifi.STORAGE_KEY]["data"][config_entry.entry_id][ - "wireless_devices" - ] - ) + ] From 31575799926e3f6b9421fef2466643bb07f3b005 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Fri, 24 Mar 2023 12:59:59 +0100 Subject: [PATCH 0740/1058] Remove duplicate code in livisi coordinator (#90227) * Simplify coordinator * remove window sensor specific code (isOpen) * parameter order, type hinta * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/livisi/climate.py | 13 ++-- .../components/livisi/coordinator.py | 76 +++++-------------- homeassistant/components/livisi/switch.py | 4 +- 3 files changed, 28 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index a6680a19af3a..3a0a219d9434 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -99,14 +99,15 @@ class LivisiClimate(LivisiEntity, ClimateEntity): await super().async_added_to_hass() - target_temperature = await self.coordinator.async_get_vrcc_target_temperature( - self._target_temperature_capability + target_temperature = await self.coordinator.async_get_device_state( + self._target_temperature_capability, + "setpointTemperature" if self.coordinator.is_avatar else "pointTemperature", ) - temperature = await self.coordinator.async_get_vrcc_temperature( - self._temperature_capability + temperature = await self.coordinator.async_get_device_state( + self._temperature_capability, "temperature" ) - humidity = await self.coordinator.async_get_vrcc_humidity( - self._humidity_capability + humidity = await self.coordinator.async_get_device_state( + self._humidity_capability, "humidity" ) if temperature is None: self._attr_current_temperature = None diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index e6c29f7151e4..58124dfa04c3 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -58,6 +58,10 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): except ClientConnectorError as exc: raise UpdateFailed("Failed to get LIVISI the devices") from exc + def _async_dispatcher_send(self, event: str, source: str, data: Any) -> None: + if data is not None: + async_dispatcher_send(self.hass, f"{event}_{source}", data) + async def async_setup(self) -> None: """Set up the Livisi Smart Home Controller.""" if not self.aiolivisi.livisi_connection_data: @@ -83,44 +87,14 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Set the discovered devices list.""" return await self.aiolivisi.async_get_devices() - async def async_get_pss_state(self, capability: str) -> bool | None: - """Set the PSS state.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( + async def async_get_device_state(self, capability: str, key: str) -> Any | None: + """Get state from livisi devices.""" + response: dict[str, Any] = await self.aiolivisi.async_get_device_state( capability[1:] ) if response is None: return None - on_state = response["onState"] - return on_state["value"] - - async def async_get_vrcc_target_temperature(self, capability: str) -> float | None: - """Get the target temperature of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - if self.is_avatar: - return response["setpointTemperature"]["value"] - return response["pointTemperature"]["value"] - - async def async_get_vrcc_temperature(self, capability: str) -> float | None: - """Get the temperature of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - return response["temperature"]["value"] - - async def async_get_vrcc_humidity(self, capability: str) -> int | None: - """Get the humidity of the climate device.""" - response: dict[str, Any] | None = await self.aiolivisi.async_get_device_state( - capability[1:] - ) - if response is None: - return None - return response["humidity"]["value"] + return response.get(key, {}).get("value") async def async_set_all_rooms(self) -> None: """Set the room list.""" @@ -132,34 +106,20 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): def on_data(self, event_data: LivisiEvent) -> None: """Define a handler to fire when the data is received.""" - if event_data.onState is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_STATE_CHANGE}_{event_data.source}", - event_data.onState, - ) - if event_data.vrccData is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_STATE_CHANGE}_{event_data.source}", - event_data.vrccData, - ) - if event_data.isReachable is not None: - async_dispatcher_send( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{event_data.source}", - event_data.isReachable, - ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.onState + ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.vrccData + ) + self._async_dispatcher_send( + LIVISI_REACHABILITY_CHANGE, event_data.source, event_data.isReachable + ) async def on_close(self) -> None: """Define a handler to fire when the websocket is closed.""" for device_id in self.devices: - is_reachable: bool = False - async_dispatcher_send( - self.hass, - f"{LIVISI_REACHABILITY_CHANGE}_{device_id}", - is_reachable, - ) + self._async_dispatcher_send(LIVISI_REACHABILITY_CHANGE, device_id, False) await self.websocket.connect(self.on_data, self.on_close, self.port) diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index 1a5789ea24e9..2c5a2b5137b5 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -81,7 +81,9 @@ class LivisiSwitch(LivisiEntity, SwitchEntity): """Register callbacks.""" await super().async_added_to_hass() - response = await self.coordinator.async_get_pss_state(self._capability_id) + response = await self.coordinator.async_get_device_state( + self._capability_id, "onState" + ) if response is None: self._attr_is_on = False self._attr_available = False From a66bef6fdf34536fb546754d48ff023c13ed1fd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Mar 2023 13:55:17 +0100 Subject: [PATCH 0741/1058] Bump actions/checkout from 3.4.0 to 3.5.0 (#90215) Bumps [actions/checkout](https://github.com/actions/checkout) from 3.4.0 to 3.5.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3.4.0...v3.5.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 +++++------ .github/workflows/ci.yaml | 32 +++++++++++++++--------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index bc21a2e3c7c3..ff53757bdd6a 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 with: fetch-depth: 0 @@ -67,7 +67,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 @@ -105,7 +105,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -249,7 +249,7 @@ jobs: - yellow steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set build additional args run: | @@ -292,7 +292,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: - "homeassistant" steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Login to DockerHub if: matrix.registry == 'homeassistant' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0dafda42128..f4e04059d154 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -203,7 +203,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -248,7 +248,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -294,7 +294,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -343,7 +343,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -381,7 +381,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 id: python @@ -487,7 +487,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -555,7 +555,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -587,7 +587,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -620,7 +620,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -664,7 +664,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.5.0 @@ -730,7 +730,7 @@ jobs: name: Run pip check ${{ matrix.python-version }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -783,7 +783,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -909,7 +909,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1017,7 +1017,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.5.0 @@ -1093,7 +1093,7 @@ jobs: - pytest steps: - name: Check out code from GitHub - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index b8cbd9204bfa..86bfa5f9bb9d 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.5.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 108673828374..63069b86ef8c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -22,7 +22,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Get information id: info @@ -82,7 +82,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -119,7 +119,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.4.0 + uses: actions/checkout@v3.5.0 - name: Download env_file uses: actions/download-artifact@v3 From 8149652f9f3f808261c01e8a43b1c692f2bae084 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Fri, 24 Mar 2023 09:20:37 -0400 Subject: [PATCH 0742/1058] Move qbittorrent constants to const.py (#90201) * move qbittorrent constants to const.py * move SENSOR_TYPE_* consts back to sensors.py --- homeassistant/components/qbittorrent/const.py | 3 +++ homeassistant/components/qbittorrent/sensor.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/qbittorrent/const.py diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py new file mode 100644 index 000000000000..5f9ad42f7fcd --- /dev/null +++ b/homeassistant/components/qbittorrent/const.py @@ -0,0 +1,3 @@ +"""Constants for qBittorrent.""" + +DEFAULT_NAME = "qBittorrent" diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 26605a876523..bee7a5d61a67 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -28,14 +28,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DEFAULT_NAME + _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" -DEFAULT_NAME = "qBittorrent" - SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, From 4c45c3c63bc164210112c111bbb7b00afef64548 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 03:39:55 -1000 Subject: [PATCH 0743/1058] Add a faster query for get_last_state_changes when the number of states is 1 (#90211) * Add a faster query for get_last_state_changes when the number of states is 1 related issue #90113 * Add a faster query for get_last_state_changes when the number of states is 1 related issue #90113 * coverage * Apply suggestions from code review --- .../components/recorder/history/modern.py | 46 +++++++++++++++---- tests/components/recorder/test_history.py | 36 +++++++++++++++ 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 50e61027036a..22bfdc3ee94a 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -406,16 +406,38 @@ def _get_last_state_changes_stmt( stmt, join_attributes = _lambda_stmt_and_join_attributes( False, include_last_changed=False ) - stmt += lambda q: q.where( - States.state_id - == ( - select(States.state_id) - .filter(States.metadata_id == metadata_id) - .order_by(States.last_updated_ts.desc()) - .limit(number_of_states) - .subquery() - ).c.state_id - ) + if number_of_states == 1: + stmt += lambda q: q.join( + ( + lastest_state_for_metadata_id := ( + select( + States.metadata_id.label("max_metadata_id"), + # https://github.com/sqlalchemy/sqlalchemy/issues/9189 + # pylint: disable-next=not-callable + func.max(States.last_updated_ts).label("max_last_updated"), + ) + .filter(States.metadata_id == metadata_id) + .group_by(States.metadata_id) + .subquery() + ) + ), + and_( + States.metadata_id == lastest_state_for_metadata_id.c.max_metadata_id, + States.last_updated_ts + == lastest_state_for_metadata_id.c.max_last_updated, + ), + ) + else: + stmt += lambda q: q.where( + States.state_id + == ( + select(States.state_id) + .filter(States.metadata_id == metadata_id) + .order_by(States.last_updated_ts.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -432,6 +454,10 @@ def get_last_state_changes( entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] + # Calling this function with number_of_states > 1 can cause instability + # because it has to scan the table to find the last number_of_states states + # because the metadata_id_last_updated_ts index is in ascending order. + with session_scope(hass=hass, read_only=True) as session: instance = recorder.get_instance(hass) if not ( diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index e39cb1945f82..e3aed8a39882 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -382,6 +382,42 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> assert_multiple_states_equal_without_context(states, hist[entity_id]) +def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> None: + """Test getting the last state change for an entity.""" + hass = hass_recorder() + entity_id = "sensor.test" + + def set_state(state): + """Set the state.""" + hass.states.set(entity_id, state) + wait_recording_done(hass) + return hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + point2 = point + timedelta(minutes=1, seconds=1) + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start + ): + set_state("1") + + states = [] + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point + ): + set_state("2") + + with patch( + "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 + ): + states.append(set_state("3")) + + hist = history.get_last_state_changes(hass, 1, entity_id) + + assert_multiple_states_equal_without_context(states, hist[entity_id]) + + def test_ensure_state_can_be_copied( hass_recorder: Callable[..., HomeAssistant] ) -> None: From 0bb0b4bfc50d0d1395e8b0c5d95317f8d2f28fa0 Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Fri, 24 Mar 2023 14:52:50 +0100 Subject: [PATCH 0744/1058] Add livisi window sensor (WDS) (#90220) * Added support for livisi window sensor * Add const strings * added postpix for device_id * Remove unnecessary import * Fix imports * Fix lint errors, remove redundant device class property * Format code * Update .coveragerc * Finish basic window door sensor support * currently, only one binary sensor (wds) is supported * Remove unused imports * Fix isort issue * Simplify code as suggested in PR * rename get_device_response to get_device_state * fix ruff issue * Be more defensive in interpreting what we get from aiolivisi * Simplify coordinator * remove window sensor specific code (isOpen) * parameter order, type hinta * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/coordinator.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/livisi/binary_sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: Tecotix <78791840+Tecotix@users.noreply.github.com> Co-authored-by: Erik Montnemery Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .coveragerc | 1 + homeassistant/components/livisi/__init__.py | 2 +- .../components/livisi/binary_sensor.py | 110 ++++++++++++++++++ homeassistant/components/livisi/const.py | 2 + .../components/livisi/coordinator.py | 3 + 5 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/livisi/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index e59c60ddccb3..24f324313ae2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -642,6 +642,7 @@ omit = homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* homeassistant/components/livisi/__init__.py + homeassistant/components/livisi/binary_sensor.py homeassistant/components/livisi/climate.py homeassistant/components/livisi/coordinator.py homeassistant/components/livisi/entity.py diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index b8d8fdbfb099..b0387c6dcc92 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN from .coordinator import LivisiDataUpdateCoordinator -PLATFORMS: Final = [Platform.CLIMATE, Platform.SWITCH] +PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SWITCH] async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/livisi/binary_sensor.py b/homeassistant/components/livisi/binary_sensor.py new file mode 100644 index 000000000000..42170bbeb4cb --- /dev/null +++ b/homeassistant/components/livisi/binary_sensor.py @@ -0,0 +1,110 @@ +"""Code to handle a Livisi Binary Sensor.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +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 DOMAIN, LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE +from .coordinator import LivisiDataUpdateCoordinator +from .entity import LivisiEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensor device.""" + coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + known_devices = set() + + @callback + def handle_coordinator_update() -> None: + """Add Window Sensor.""" + shc_devices: list[dict[str, Any]] = coordinator.data + entities: list[BinarySensorEntity] = [] + for device in shc_devices: + if device["id"] not in known_devices and device["type"] == WDS_DEVICE_TYPE: + livisi_binary: BinarySensorEntity = LivisiWindowDoorSensor( + config_entry, coordinator, device + ) + LOGGER.debug("Include device type: %s", device["type"]) + coordinator.devices.add(device["id"]) + known_devices.add(device["id"]) + entities.append(livisi_binary) + async_add_entities(entities) + + config_entry.async_on_unload( + coordinator.async_add_listener(handle_coordinator_update) + ) + + +class LivisiBinarySensor(LivisiEntity, BinarySensorEntity): + """Represents a Livisi Binary Sensor.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + capability_name: str, + ) -> None: + """Initialize the Livisi sensor.""" + super().__init__(config_entry, coordinator, device) + self._capability_id = self.capabilities[capability_name] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{LIVISI_STATE_CHANGE}_{self._capability_id}", + self.update_states, + ) + ) + + @callback + def update_states(self, state: bool) -> None: + """Update the state of the device.""" + self._attr_is_on = state + self.async_write_ha_state() + + +class LivisiWindowDoorSensor(LivisiBinarySensor): + """Represents a Livisi Window/Door Sensor as a Binary Sensor Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: LivisiDataUpdateCoordinator, + device: dict[str, Any], + ) -> None: + """Initialize the Livisi window/door sensor.""" + super().__init__(config_entry, coordinator, device, "WindowDoorSensor") + + self._attr_device_class = ( + BinarySensorDeviceClass.DOOR + if (device.get("tags", {}).get("typeCategory") == "TCDoorId") + else BinarySensorDeviceClass.WINDOW + ) + + async def async_added_to_hass(self) -> None: + """Get current state.""" + await super().async_added_to_hass() + response = await self.coordinator.async_get_device_state( + self._capability_id, "isOpen" + ) + if response is None: + self._attr_available = False + else: + self._attr_is_on = response diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index 98e0b7816c63..f6435298f1e3 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -16,6 +16,8 @@ LIVISI_REACHABILITY_CHANGE: Final = "livisi_reachability_change" SWITCH_DEVICE_TYPES: Final = ["ISS", "ISS2", "PSS", "PSSO"] VRCC_DEVICE_TYPE: Final = "VRCC" +WDS_DEVICE_TYPE: Final = "WDS" + MAX_TEMPERATURE: Final = 30.0 MIN_TEMPERATURE: Final = 6.0 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 58124dfa04c3..f745a66e827e 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -115,6 +115,9 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): self._async_dispatcher_send( LIVISI_REACHABILITY_CHANGE, event_data.source, event_data.isReachable ) + self._async_dispatcher_send( + LIVISI_STATE_CHANGE, event_data.source, event_data.isOpen + ) async def on_close(self) -> None: """Define a handler to fire when the websocket is closed.""" From 72b09bfee7dd54d28db4f6eada3153a9c58b3966 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 09:37:43 -1000 Subject: [PATCH 0745/1058] Subclass aiohttp requests to use json helper (#90214) * Subclass aiohttp requests to use json helper * Subclass aiohttp requests to use json helper * remove unneeded * revert for new pr * override loads is never used so drop it * override loads is never used so drop it --- homeassistant/components/http/__init__.py | 44 +++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3106eea05faa..2d306ba5ee59 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,6 +1,7 @@ """Support to serve the Home Assistant API as WSGI application.""" from __future__ import annotations +import asyncio import datetime from ipaddress import IPv4Network, IPv6Network, ip_network import logging @@ -10,9 +11,13 @@ from tempfile import NamedTemporaryFile from typing import Any, Final, TypedDict, cast from aiohttp import web -from aiohttp.typedefs import StrOrURL +from aiohttp.abc import AbstractStreamWriter +from aiohttp.http_parser import RawRequestMessage +from aiohttp.streams import StreamReader +from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection from aiohttp.web_log import AccessLogger +from aiohttp.web_protocol import RequestHandler from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -31,6 +36,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.setup import async_start_setup, async_when_setup_or_start from homeassistant.util import ssl as ssl_util +from homeassistant.util.json import json_loads from .auth import async_setup_auth from .ban import setup_bans @@ -240,6 +246,40 @@ class HomeAssistantAccessLogger(AccessLogger): super().log(request, response, time) +class HomeAssistantRequest(web.Request): + """Home Assistant request object.""" + + async def json(self, *, loads: JSONDecoder = json_loads) -> Any: + """Return body as JSON.""" + # json_loads is a wrapper around orjson.loads that handles + # bytes and str. We can pass the bytes directly to json_loads. + return json_loads(await self.read()) + + +class HomeAssistantApplication(web.Application): + """Home Assistant application.""" + + def _make_request( + self, + message: RawRequestMessage, + payload: StreamReader, + protocol: RequestHandler, + writer: AbstractStreamWriter, + task: asyncio.Task[None], + _cls: type[web.Request] = HomeAssistantRequest, + ) -> web.Request: + """Create request instance.""" + return _cls( + message, + payload, + protocol, + writer, + task, + loop=self._loop, + client_max_size=self._client_max_size, + ) + + class HomeAssistantHTTP: """HTTP server for Home Assistant.""" @@ -255,7 +295,7 @@ class HomeAssistantHTTP: ssl_profile: str, ) -> None: """Initialize the HTTP Home Assistant server.""" - self.app = web.Application( + self.app = HomeAssistantApplication( middlewares=[], client_max_size=MAX_CLIENT_SIZE, handler_args={ From 8e07b716444431a78a9ec9a18cc30c2c404440ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 09:38:08 -1000 Subject: [PATCH 0746/1058] Use the json load helper in a few more incoming web requests (#90194) * Use the json load helper in a few more incoming web requests * drop hassio change as there is no coverage there * Remove everything except emulated_hue since its has its own site/web --- homeassistant/components/emulated_hue/hue_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 41c25943a772..f779f5d8e946 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -64,6 +64,7 @@ from homeassistant.const import ( ) from homeassistant.core import State from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.util.json import json_loads from homeassistant.util.network import is_local from .config import Config @@ -138,7 +139,7 @@ class HueUsernameView(HomeAssistantView): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) try: - data = await request.json() + data = await request.json(loads=json_loads) except ValueError: return self.json_message("Invalid JSON", HTTPStatus.BAD_REQUEST) From e17cefd61cbd234e7dab8b94d4847858b29c0e40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Mar 2023 10:24:02 -1000 Subject: [PATCH 0747/1058] Clear recorder startup tasks from memory after processing (#90240) Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8e522a2bbd9d..3b92698c83d6 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -779,6 +779,10 @@ class Recorder(threading.Thread): for task in startup_tasks: self._guarded_process_one_task_or_recover(task) + # Clear startup tasks since this thread runs forever + # and we don't want to hold them in memory + del startup_tasks + self.stop_requested = False while not self.stop_requested: self._guarded_process_one_task_or_recover(queue_.get()) From 7f1fff12effa5d35c32c950bde1c59dfe572f5e8 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 25 Mar 2023 00:27:16 +0100 Subject: [PATCH 0748/1058] Bump aiounifi to v45 (#90250) * Bump aiounifi to v45 * Replace local TypeVar with library TypeVar --- .../components/unifi/device_tracker.py | 10 ++++---- homeassistant/components/unifi/entity.py | 23 ++++++++----------- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/sensor.py | 14 +++++------ homeassistant/components/unifi/switch.py | 14 +++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 31 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index f891416c6358..f31176afe382 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -12,6 +12,7 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.devices import Devices +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey @@ -26,7 +27,6 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, UnifiEntity, UnifiEntityDescription, @@ -136,7 +136,7 @@ def async_device_heartbeat_timedelta_fn( @dataclass -class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] @@ -147,8 +147,8 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, DataT]): @dataclass class UnifiTrackerEntityDescription( - UnifiEntityDescription[HandlerT, DataT], - UnifiEntityTrackerDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiEntityTrackerDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi device tracker entity.""" @@ -211,7 +211,7 @@ async def async_setup_entry( ) -class UnifiScannerEntity(UnifiEntity[HandlerT, DataT], ScannerEntity): +class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): """Representation of a UniFi scanner.""" entity_description: UnifiTrackerEntityDescription diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 5d763ecfe8ad..18a132be6a8b 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -13,12 +13,8 @@ from aiounifi.interfaces.api_handlers import ( ItemEvent, UnsubscribeType, ) -from aiounifi.interfaces.outlets import Outlets -from aiounifi.interfaces.ports import Ports -from aiounifi.models.api import APIItem +from aiounifi.models.api import ApiItemT from aiounifi.models.event import Event, EventKey -from aiounifi.models.outlet import Outlet -from aiounifi.models.port import Port from homeassistant.core import callback from homeassistant.helpers import entity_registry as er @@ -31,8 +27,7 @@ from .const import ATTR_MANUFACTURER if TYPE_CHECKING: from .controller import UniFiController -DataT = TypeVar("DataT", bound=APIItem | Outlet | Port) -HandlerT = TypeVar("HandlerT", bound=APIHandler | Outlets | Ports) +HandlerT = TypeVar("HandlerT", bound=APIHandler) SubscriptionT = Callable[[CallbackType, ItemEvent], UnsubscribeType] @@ -64,7 +59,7 @@ def async_device_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @dataclass -class UnifiDescription(Generic[HandlerT, DataT]): +class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" allowed_fn: Callable[[UniFiController, str], bool] @@ -73,21 +68,21 @@ class UnifiDescription(Generic[HandlerT, DataT]): device_info_fn: Callable[[aiounifi.Controller, str], DeviceInfo | None] event_is_on: tuple[EventKey, ...] | None event_to_subscribe: tuple[EventKey, ...] | None - name_fn: Callable[[DataT], str | None] - object_fn: Callable[[aiounifi.Controller, str], DataT] + name_fn: Callable[[ApiItemT], str | None] + object_fn: Callable[[aiounifi.Controller, str], ApiItemT] supported_fn: Callable[[UniFiController, str], bool | None] unique_id_fn: Callable[[UniFiController, str], str] @dataclass -class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, DataT]): +class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, ApiItemT]): """UniFi Entity Description.""" -class UnifiEntity(Entity, Generic[HandlerT, DataT]): +class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): """Representation of a UniFi entity.""" - entity_description: UnifiEntityDescription[HandlerT, DataT] + entity_description: UnifiEntityDescription[HandlerT, ApiItemT] _attr_should_poll = False _attr_unique_id: str @@ -96,7 +91,7 @@ class UnifiEntity(Entity, Generic[HandlerT, DataT]): self, obj_id: str, controller: UniFiController, - description: UnifiEntityDescription[HandlerT, DataT], + description: UnifiEntityDescription[HandlerT, ApiItemT], ) -> None: """Set up UniFi switch entity.""" self._obj_id = obj_id diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 92f879c10473..7fde8a2ad7e6 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==44"], + "requirements": ["aiounifi==45"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 420fc3803c3d..3682fa0bf6cd 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -14,6 +14,7 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.ports import Ports +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.port import Port @@ -33,7 +34,6 @@ import homeassistant.util.dt as dt_util from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, UnifiEntity, UnifiEntityDescription, @@ -80,17 +80,17 @@ def async_client_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @dataclass -class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" - value_fn: Callable[[UniFiController, DataT], datetime | float | str | None] + value_fn: Callable[[UniFiController, ApiItemT], datetime | float | str | None] @dataclass class UnifiSensorEntityDescription( SensorEntityDescription, - UnifiEntityDescription[HandlerT, DataT], - UnifiSensorEntityDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiSensorEntityDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi sensor entity.""" @@ -182,10 +182,10 @@ async def async_setup_entry( ) -class UnifiSensorEntity(UnifiEntity[HandlerT, DataT], SensorEntity): +class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): """Base representation of a UniFi sensor.""" - entity_description: UnifiSensorEntityDescription[HandlerT, DataT] + entity_description: UnifiSensorEntityDescription[HandlerT, ApiItemT] @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index bf724cec1fd7..bd0166516dc8 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -17,6 +17,7 @@ from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.ports import Ports +from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client, ClientBlockRequest from aiounifi.models.device import ( DeviceSetOutletRelayRequest, @@ -47,7 +48,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( - DataT, HandlerT, SubscriptionT, UnifiEntity, @@ -136,18 +136,18 @@ async def async_poe_port_control_fn( @dataclass -class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, DataT]): +class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" control_fn: Callable[[aiounifi.Controller, str, bool], Coroutine[Any, Any, None]] - is_on_fn: Callable[[UniFiController, DataT], bool] + is_on_fn: Callable[[UniFiController, ApiItemT], bool] @dataclass class UnifiSwitchEntityDescription( SwitchEntityDescription, - UnifiEntityDescription[HandlerT, DataT], - UnifiSwitchEntityDescriptionMixin[HandlerT, DataT], + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiSwitchEntityDescriptionMixin[HandlerT, ApiItemT], ): """Class describing UniFi switch entity.""" @@ -255,10 +255,10 @@ async def async_setup_entry( ) -class UnifiSwitchEntity(UnifiEntity[HandlerT, DataT], SwitchEntity): +class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): """Base representation of a UniFi switch.""" - entity_description: UnifiSwitchEntityDescription[HandlerT, DataT] + entity_description: UnifiSwitchEntityDescription[HandlerT, ApiItemT] only_event_for_state_change = False @callback diff --git a/requirements_all.txt b/requirements_all.txt index 7d1670833f23..e4b5a9999d7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,7 +291,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==44 +aiounifi==45 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bb56a1be494..2e31358bca92 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -272,7 +272,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==44 +aiounifi==45 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 5f3868b1419e5286777da7d5483e1a3266d98486 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Mar 2023 03:34:01 +0100 Subject: [PATCH 0749/1058] Add missing type hints to tests (#90218) * Add type hints to tests * Revert gree as handled in #90222 --- tests/components/calendar/test_trigger.py | 2 +- .../local_calendar/test_diagnostics.py | 7 +++---- tests/components/zha/test_api.py | 19 +++++++++++-------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/components/calendar/test_trigger.py b/tests/components/calendar/test_trigger.py index 9e15a1996dcc..e210bd7ac30b 100644 --- a/tests/components/calendar/test_trigger.py +++ b/tests/components/calendar/test_trigger.py @@ -668,7 +668,7 @@ async def test_trigger_timestamp_window_edge( async def test_event_start_trigger_dst( - hass: HomeAssistant, calls, fake_schedule, freezer + hass: HomeAssistant, calls, fake_schedule, freezer: FrozenDateTimeFactory ) -> None: """Test a calendar event trigger happening at the start of daylight savings time.""" tzinfo = zoneinfo.ZoneInfo("America/Los_Angeles") diff --git a/tests/components/local_calendar/test_diagnostics.py b/tests/components/local_calendar/test_diagnostics.py index 561f7588a510..9a1da25d7701 100644 --- a/tests/components/local_calendar/test_diagnostics.py +++ b/tests/components/local_calendar/test_diagnostics.py @@ -1,5 +1,4 @@ """Tests for diagnostics platform of local calendar.""" - from aiohttp.test_utils import TestClient from freezegun import freeze_time import pytest @@ -9,11 +8,11 @@ from homeassistant.auth.models import Credentials from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY, Client, ClientFixture +from .conftest import TEST_ENTITY, Client from tests.common import CLIENT_ID, MockConfigEntry, MockUser from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def generate_new_hass_access_token( @@ -82,7 +81,7 @@ async def test_api_date_time_event( hass_admin_user: MockUser, hass_admin_credential: Credentials, config_entry: MockConfigEntry, - hass_ws_client: ClientFixture, + hass_ws_client: WebSocketGenerator, aiohttp_client: ClientSessionGenerator, socket_enabled: None, snapshot: SnapshotAssertion, diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 0d03b62bf878..c60790998048 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -7,6 +7,7 @@ import zigpy.state from homeassistant.components import zha from homeassistant.components.zha import api from homeassistant.components.zha.core.const import RadioType +from homeassistant.core import HomeAssistant @pytest.fixture(autouse=True) @@ -16,7 +17,9 @@ def required_platform_only(): yield -async def test_async_get_network_settings_active(hass, setup_zha): +async def test_async_get_network_settings_active( + hass: HomeAssistant, setup_zha +) -> None: """Test reading settings with an active ZHA installation.""" await setup_zha() @@ -25,8 +28,8 @@ async def test_async_get_network_settings_active(hass, setup_zha): async def test_async_get_network_settings_inactive( - hass, setup_zha, zigpy_app_controller -): + hass: HomeAssistant, setup_zha, zigpy_app_controller +) -> None: """Test reading settings with an inactive ZHA installation.""" await setup_zha() @@ -48,8 +51,8 @@ async def test_async_get_network_settings_inactive( async def test_async_get_network_settings_missing( - hass, setup_zha, zigpy_app_controller -): + hass: HomeAssistant, setup_zha, zigpy_app_controller +) -> None: """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() @@ -69,13 +72,13 @@ async def test_async_get_network_settings_missing( assert settings is None -async def test_async_get_network_settings_failure(hass): +async def test_async_get_network_settings_failure(hass: HomeAssistant) -> None: """Test reading settings with no ZHA config entries and no database.""" with pytest.raises(ValueError): await api.async_get_network_settings(hass) -async def test_async_get_radio_type_active(hass, setup_zha): +async def test_async_get_radio_type_active(hass: HomeAssistant, setup_zha) -> None: """Test reading the radio type with an active ZHA installation.""" await setup_zha() @@ -83,7 +86,7 @@ async def test_async_get_radio_type_active(hass, setup_zha): assert radio_type == RadioType.ezsp -async def test_async_get_radio_path_active(hass, setup_zha): +async def test_async_get_radio_path_active(hass: HomeAssistant, setup_zha) -> None: """Test reading the radio path with an active ZHA installation.""" await setup_zha() From f56bf134d280fe9085e7fb56c5f419101937e73d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Mar 2023 05:49:12 +0100 Subject: [PATCH 0750/1058] Improve browse_media type hints in media player (#90060) * Improve browse_media type hints in media player * Adjust components * Adjust base entity --- homeassistant/components/braviatv/media_player.py | 2 +- homeassistant/components/dlna_dmr/media_player.py | 2 +- homeassistant/components/esphome/media_player.py | 4 +++- homeassistant/components/forked_daapd/media_player.py | 2 +- homeassistant/components/frontier_silicon/media_player.py | 4 +++- homeassistant/components/fully_kiosk/media_player.py | 2 +- homeassistant/components/gstreamer/media_player.py | 4 +++- homeassistant/components/heos/media_player.py | 4 +++- homeassistant/components/jellyfin/media_player.py | 4 +++- homeassistant/components/kodi/media_player.py | 4 +++- homeassistant/components/media_player/__init__.py | 2 +- homeassistant/components/mpd/media_player.py | 4 +++- homeassistant/components/openhome/media_player.py | 4 +++- homeassistant/components/panasonic_viera/media_player.py | 4 +++- homeassistant/components/philips_js/media_player.py | 4 +++- homeassistant/components/plex/media_player.py | 4 +++- homeassistant/components/roku/media_player.py | 2 +- homeassistant/components/roon/media_player.py | 4 +++- homeassistant/components/slimproto/media_player.py | 4 +++- homeassistant/components/sonos/media_player.py | 4 +++- homeassistant/components/soundtouch/media_player.py | 4 +++- homeassistant/components/spotify/media_player.py | 4 +++- homeassistant/components/universal/media_player.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- 24 files changed, 56 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 917bd1d5419f..c09df32aea3a 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -136,7 +136,7 @@ class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Browse apps and channels.""" diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index a866b911f391..eddb2633beac 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -767,7 +767,7 @@ class DlnaDmrEntity(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper. diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index f8566e863c6b..673a90580e0e 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -115,7 +115,9 @@ class EsphomeMediaPlayer( ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + 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( diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index ca7e0cce27cd..d5f40c37b516 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -836,7 +836,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index b05ba272a19d..7f73823239c9 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -328,7 +328,9 @@ class AFSAPIDevice(MediaPlayerEntity): await self.fs_device.set_eq_preset(mode) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Browse media library and preset stations.""" if not media_content_id: diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index 0fcd8c3543fd..8c73d47dd743 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -72,7 +72,7 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the WebSocket media browsing helper.""" diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index 04e91e43172d..cb221d49417c 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -166,7 +166,9 @@ class GstreamerDevice(MediaPlayerEntity): return self._album async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + 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( diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 3147c1e16606..9ad33caf0734 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -427,7 +427,9 @@ class HeosMediaPlayer(MediaPlayerEntity): return self._player.volume / 100 async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + 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( diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 32ca1d59d717..2025e1a2a6cd 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -283,7 +283,9 @@ class JellyfinMediaPlayer(JellyfinEntity, MediaPlayerEntity): self.coordinator.api_client.jellyfin.remote_unmute(self.session_id) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance. diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 029eedb242d1..63875236bef0 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -884,7 +884,9 @@ class KodiEntity(MediaPlayerEntity): return sorted(out, key=lambda out: out[1], reverse=True) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" is_internal = is_internal_request(self.hass) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 3938cc64f7b9..8810ea165d6c 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1037,7 +1037,7 @@ class MediaPlayerEntity(Entity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance. diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 7395777320c0..457f9058242d 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -509,7 +509,9 @@ class MpdDevice(MediaPlayerEntity): await self._client.seekcur(position) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + 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( diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 68357c862c43..b625d9976da2 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -347,7 +347,9 @@ class OpenhomeDevice(MediaPlayerEntity): await self._device.set_mute(mute) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + 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( diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index 8b676f37c269..5e2ed77233be 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -203,7 +203,9 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): await self._remote.async_play_media(media_type, media_id) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + 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) diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 89cb29f0a078..e8250dc8eba5 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -391,7 +391,9 @@ class PhilipsTVMediaPlayer( ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" if not self._tv.on: diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index c1a3ac5bd314..be5726796059 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -541,7 +541,9 @@ class PlexMediaPlayer(MediaPlayerEntity): ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" is_internal = is_internal_request(self.hass) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index b0191f605d14..cf6563519ff5 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -278,7 +278,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 307765da5cf1..3bcafe4ba9a2 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -498,7 +498,9 @@ class RoonDevice(MediaPlayerEntity): ) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await self.hass.async_add_executor_job( diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 597ed50f4285..641d3b8ae4d9 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -195,7 +195,9 @@ class SlimProtoPlayer(MediaPlayerEntity): await self.player.play_url(media_id, mime_type=to_send_media_type) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + 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( diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index fbd74e57742f..1ef86429cb4e 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -712,7 +712,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): return (None, None) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" return await media_browser.async_browse_media( diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 111a13c2c906..721184313309 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -398,7 +398,9 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): return attributes async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + 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) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 7c583eb5335f..b63a9513818e 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -398,7 +398,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self._playlist = self.data.client.playlist(current["context"]["uri"]) async def async_browse_media( - self, media_content_type: str | None = None, media_content_id: str | None = None + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 21d741d34551..fd73ad33e16f 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -630,7 +630,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): async def async_browse_media( self, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> BrowseMedia: """Return a BrowseMedia instance.""" diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 6394f8422260..f25b8db84afa 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2003,7 +2003,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="async_browse_media", arg_types={ - 1: "str | None", + 1: "MediaType | str | None", 2: "str | None", }, return_type="BrowseMedia", From 970036b32873f31c76ab5979aebdc81181af83f0 Mon Sep 17 00:00:00 2001 From: Jeef Date: Sat, 25 Mar 2023 02:15:46 -0600 Subject: [PATCH 0751/1058] Refactor Gree switch to use EntityDescription (#90143) * Post-rebase * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * feat: Tests passing! * Removing custom attributes as no longer needed * removed extraneous class * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/gree/switch.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update tests/components/gree/test_switch.py Co-authored-by: solazs * Update tests/components/gree/test_switch.py Co-authored-by: solazs --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: solazs --- homeassistant/components/gree/switch.py | 193 ++++++------------------ 1 file changed, 42 insertions(+), 151 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index ffef6b08a94c..0ac740671344 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,9 +1,13 @@ """Support for interface with a Gree climate systems.""" from __future__ import annotations -from typing import Any +from typing import Any, cast -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -12,6 +16,29 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN from .entity import GreeEntity +GREE_SWITCHES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + icon="mdi:lightbulb", + name="Panel Light", + key="light", + ), + SwitchEntityDescription( + name="Quiet", + key="quiet", + ), + SwitchEntityDescription( + name="Fresh Air", + key="fresh_air", + ), + SwitchEntityDescription(name="XFan", key="xfan"), + SwitchEntityDescription( + icon="mdi:pine-tree", + name="Health mode", + key="anion", + entity_registry_enabled_default=False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -23,14 +50,10 @@ async def async_setup_entry( @callback def init_device(coordinator): """Register the device.""" + async_add_entities( - [ - GreePanelLightSwitchEntity(coordinator), - GreeHealthModeSwitchEntity(coordinator), - GreeQuietModeSwitchEntity(coordinator), - GreeFreshAirSwitchEntity(coordinator), - GreeXFanSwitchEntity(coordinator), - ] + GreeSwitch(coordinator=coordinator, description=description) + for description in GREE_SWITCHES ) for coordinator in hass.data[DOMAIN][COORDINATORS]: @@ -41,162 +64,30 @@ async def async_setup_entry( ) -class GreePanelLightSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the front panel light on the device.""" +class GreeSwitch(GreeEntity, SwitchEntity): + """Generic Gree switch entity.""" - def __init__(self, coordinator): + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, coordinator, description: SwitchEntityDescription) -> None: """Initialize the Gree device.""" - super().__init__(coordinator, "Panel Light") + self.entity_description = description - @property - def icon(self) -> str | None: - """Return the icon for the device.""" - return "mdi:lightbulb" - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the light is turned on.""" - return self.coordinator.device.light - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.light = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.light = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeHealthModeSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the health mode on the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Health mode") - self._attr_entity_registry_enabled_default = False - - @property - def icon(self) -> str | None: - """Return the icon for the device.""" - return "mdi:pine-tree" - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the health mode is turned on.""" - return self.coordinator.device.anion - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.anion = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.anion = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeQuietModeSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the quiet mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Quiet") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH + super().__init__(coordinator, cast(str, description.name)) @property def is_on(self) -> bool: """Return if the state is turned on.""" - return self.coordinator.device.quiet + return getattr(self.coordinator.device, self.entity_description.key) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - self.coordinator.device.quiet = True + setattr(self.coordinator.device, self.entity_description.key, True) await self.coordinator.push_state_update() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self.coordinator.device.quiet = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeFreshAirSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the fresh air mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "Fresh Air") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the state is turned on.""" - return self.coordinator.device.fresh_air - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.fresh_air = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.fresh_air = False - await self.coordinator.push_state_update() - self.async_write_ha_state() - - -class GreeXFanSwitchEntity(GreeEntity, SwitchEntity): - """Representation of the extra fan mode state of the device.""" - - def __init__(self, coordinator): - """Initialize the Gree device.""" - super().__init__(coordinator, "XFan") - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SwitchDeviceClass.SWITCH - - @property - def is_on(self) -> bool: - """Return if the state is turned on.""" - return self.coordinator.device.xfan - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" - self.coordinator.device.xfan = True - await self.coordinator.push_state_update() - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - self.coordinator.device.xfan = False + setattr(self.coordinator.device, self.entity_description.key, False) await self.coordinator.push_state_update() self.async_write_ha_state() From 52a94dd2ac50b9035555e69100db33e968e9365d Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 24 Mar 2023 21:36:23 -1100 Subject: [PATCH 0752/1058] Check for empty lists in KNX address configuration (#90249) --- homeassistant/components/knx/schema.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index c6206e883e50..a505714c0d0a 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -101,7 +101,11 @@ def ga_validator(value: Any) -> str | int: ) -ga_list_validator = vol.All(cv.ensure_list, [ga_validator]) +ga_list_validator = vol.All( + cv.ensure_list, + [ga_validator], + vol.IsTrue("value must be a group address or a list containing group addresses"), +) ia_validator = vol.Any( vol.All(str, str.strip, cv.matches_regex(IndividualAddress.ADDRESS_RE.pattern)), From 02ef7d445d8a723fe57712f37ca9c27595c5d1a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 04:11:14 -1000 Subject: [PATCH 0753/1058] Allow passing an optional name to async_track_time_interval (#90244) * Allow passing an optional name to async_track_time_interval This is the same idea as passing a name to asyncio.create_task which makes it easier to track down bugs * more * short * still cannot find it * add a few more * test --- .../components/analytics/__init__.py | 4 +++- homeassistant/components/august/subscriber.py | 2 +- .../components/bluetooth/base_scanner.py | 10 +++++++-- homeassistant/components/bluetooth/manager.py | 1 + homeassistant/components/bond/entity.py | 5 ++++- homeassistant/components/camera/__init__.py | 4 +++- .../components/device_tracker/legacy.py | 7 +++++- homeassistant/components/dhcp/__init__.py | 2 +- .../homekit_controller/connection.py | 6 ++++- homeassistant/components/recorder/core.py | 15 ++++++++++--- homeassistant/components/ssdp/__init__.py | 2 +- homeassistant/helpers/entity_platform.py | 1 + homeassistant/helpers/event.py | 10 ++++++--- homeassistant/helpers/restore_state.py | 5 ++++- tests/helpers/test_event.py | 22 +++++++++++++++++++ 15 files changed, 79 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index ad53fb03113b..7bf55480eb1a 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -27,7 +27,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: async_call_later(hass, 900, analytics.send_analytics) # Send every day - async_track_time_interval(hass, analytics.send_analytics, INTERVAL) + async_track_time_interval( + hass, analytics.send_analytics, INTERVAL, "analytics daily" + ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule) diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 5223b8b4a388..e0982fe9fb2d 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -38,7 +38,7 @@ class AugustSubscriberMixin: def _async_setup_listeners(self): """Create interval and stop listeners.""" self._unsub_interval = async_track_time_interval( - self._hass, self._async_refresh, self._update_interval + self._hass, self._async_refresh, self._update_interval, "august refresh" ) @callback diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 1c16639d6139..f1ffd6ecf582 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -98,7 +98,10 @@ class BaseHaScanner(ABC): self._start_time = self._last_detection = MONOTONIC_TIME() if not self._cancel_watchdog: self._cancel_watchdog = async_track_time_interval( - self.hass, self._async_scanner_watchdog, SCANNER_WATCHDOG_INTERVAL + self.hass, + self._async_scanner_watchdog, + SCANNER_WATCHDOG_INTERVAL, + f"{self.name} Bluetooth scanner watchdog", ) @hass_callback @@ -224,7 +227,10 @@ class BaseHaRemoteScanner(BaseHaScanner): self._async_expire_devices(dt_util.utcnow()) cancel_track = async_track_time_interval( - self.hass, self._async_expire_devices, timedelta(seconds=30) + self.hass, + self._async_expire_devices, + timedelta(seconds=30), + f"{self.name} Bluetooth scanner device expire", ) cancel_stop = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self._async_save_history diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index bc210516562f..7932520b4541 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -276,6 +276,7 @@ class BluetoothManager: self.hass, self._async_check_unavailable, timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), + "Bluetooth manager unavailable tracking", ) @hass_callback diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 8c9fef6bd7f3..d00646d6ff44 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -174,7 +174,10 @@ class BondEntity(Entity): self._bpup_subs.subscribe(self._device_id, self._async_bpup_callback) self.async_on_remove( async_track_time_interval( - self.hass, self._async_update_if_bpup_not_alive, _FALLBACK_SCAN_INTERVAL + self.hass, + self._async_update_if_bpup_not_alive, + _FALLBACK_SCAN_INTERVAL, + f"Bond {self.entity_id} fallback polling", ) ) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index e368779e9446..673009268c1d 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -379,7 +379,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity.async_update_token() entity.async_write_ha_state() - unsub = async_track_time_interval(hass, update_tokens, TOKEN_CHANGE_INTERVAL) + unsub = async_track_time_interval( + hass, update_tokens, TOKEN_CHANGE_INTERVAL, "Camera update tokens" + ) @callback def unsub_track_time_interval(_event: Event) -> None: diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index bc792ee89207..af70939ee6b0 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -423,7 +423,12 @@ def async_setup_scanner_platform( hass.async_create_task(async_see_device(**kwargs)) - async_track_time_interval(hass, async_device_tracker_scan, interval) + async_track_time_interval( + hass, + async_device_tracker_scan, + interval, + f"device_tracker {platform} legacy scan", + ) hass.async_create_task(async_device_tracker_scan(None)) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 74dd1f66abf9..ea5a5fb79ad4 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -260,7 +260,7 @@ class NetworkWatcher(WatcherBase): """Start scanning for new devices on the network.""" self._discover_hosts = DiscoverHosts() self._unsub = async_track_time_interval( - self.hass, self.async_start_discover, SCAN_INTERVAL + self.hass, self.async_start_discover, SCAN_INTERVAL, "DHCP network watcher" ) self.async_start_discover() diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 4814e7833cfa..9e56c7c24ee2 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -272,6 +272,7 @@ class HKDevice: self.hass, self.async_update_available_state, timedelta(seconds=BLE_AVAILABILITY_CHECK_INTERVAL), + f"HomeKit Controller {self.unique_id} BLE availability check poll", ) ) # BLE devices always get an RSSI sensor as well @@ -286,7 +287,10 @@ class HKDevice: # in the log about concurrent polling. self.config_entry.async_on_unload( async_track_time_interval( - self.hass, self.async_request_update, self.pairing.poll_interval + self.hass, + self.async_request_update, + self.pairing.poll_interval, + f"HomeKit Controller {self.unique_id} availability check poll", ) ) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 3b92698c83d6..fbc929b17d18 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -296,7 +296,10 @@ class Recorder(threading.Thread): run_immediately=True, ) self._queue_watcher = async_track_time_interval( - self.hass, self._async_check_queue, timedelta(minutes=10) + self.hass, + self._async_check_queue, + timedelta(minutes=10), + "Recorder queue watcher", ) @callback @@ -596,13 +599,19 @@ class Recorder(threading.Thread): # to prevent errors from unexpected disconnects if self.dialect_name != SupportedDialect.SQLITE: self._keep_alive_listener = async_track_time_interval( - self.hass, self._async_keep_alive, timedelta(seconds=KEEPALIVE_TIME) + self.hass, + self._async_keep_alive, + timedelta(seconds=KEEPALIVE_TIME), + "Recorder keep alive", ) # If the commit interval is not 0, we need to commit periodically if self.commit_interval: self._commit_listener = async_track_time_interval( - self.hass, self._async_commit, timedelta(seconds=self.commit_interval) + self.hass, + self._async_commit, + timedelta(seconds=self.commit_interval), + "Recorder commit", ) # Run nightly tasks at 4:12am diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index b7e28f270457..b7ed28885cbf 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -401,7 +401,7 @@ class Scanner: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self._cancel_scan = async_track_time_interval( - self.hass, self.async_scan, SCAN_INTERVAL + self.hass, self.async_scan, SCAN_INTERVAL, "SSDP scanner" ) # Trigger the initial-scan. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 6687af6a27d6..7b7b809404fe 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -479,6 +479,7 @@ class EntityPlatform: self.hass, self._update_entity_states, self.scan_interval, + f"EntityPlatform poll {self.domain}.{self.platform_name}", ) def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 3ac715426e3e..a456e12fa134 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1397,6 +1397,7 @@ def async_track_time_interval( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], interval: timedelta, + name: str | None = None, ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" remove: CALLBACK_TYPE @@ -1419,9 +1420,12 @@ def async_track_time_interval( ) hass.async_run_hass_job(job, now) - interval_listener_job = HassJob( - interval_listener, f"track time interval listener {interval}" - ) + if name: + job_name = f"{name}: track time interval {interval}" + else: + job_name = f"track time interval {interval}" + + interval_listener_job = HassJob(interval_listener, job_name) remove = async_track_point_in_utc_time(hass, interval_listener_job, next_interval()) def remove_listener() -> None: diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 0263bd286828..e35a66ada8d9 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -216,7 +216,10 @@ class RestoreStateData: # Dump states periodically cancel_interval = async_track_time_interval( - self.hass, _async_dump_states, STATE_DUMP_INTERVAL + self.hass, + _async_dump_states, + STATE_DUMP_INTERVAL, + "RestoreStateData dump states", ) async def _async_dump_states_at_stop(*_: Any) -> None: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 211663babc8b..7e84d634effb 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3438,6 +3438,28 @@ async def test_track_time_interval(hass: HomeAssistant) -> None: assert len(specific_runs) == 2 +async def test_track_time_interval_name(hass: HomeAssistant) -> None: + """Test tracking time interval name. + + This test is to ensure that when a name is passed to async_track_time_interval, + that the name can be found in the TimerHandle when stringified. + """ + specific_runs = [] + unique_string = "xZ13" + unsub = async_track_time_interval( + hass, + callback(lambda x: specific_runs.append(x)), + timedelta(seconds=10), + unique_string, + ) + scheduled = getattr(hass.loop, "_scheduled") + assert any(handle for handle in scheduled if unique_string in str(handle)) + unsub() + + assert all(handle for handle in scheduled if unique_string not in str(handle)) + await hass.async_block_till_done() + + async def test_track_sunrise(hass: HomeAssistant) -> None: """Test track the sunrise.""" latitude = 32.87336 From 6d8eaa0beecee7d229929ccd6ec665ee24dde771 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Sat, 25 Mar 2023 17:43:49 +0100 Subject: [PATCH 0754/1058] Add location field to calendar create_event service supported by Google Calendar and Local Calendar (#90098) * Update __init__.py * Update __init__.py * Update __init__.py * Update calendar.py * Update calendar.py * Update services.yaml * Update services.yaml * Update calendar.py * Update calendar.py * Update __init__.py * Update services.yaml * Update services.yaml * Update test_calendar.py * Update test_init.py * Update test_init.py * Update test_init.py * Update test_init.py * Update __init__.py * Update const.py * Address changes to service.yaml * Address changes to service.yaml * Update test_calendar.py * Update test_calendar.py * Update test_calendar.py * Update conftest.py * Update conftest.py * Update calendar.py * Update __init__.py --- homeassistant/components/calendar/__init__.py | 3 +++ homeassistant/components/calendar/services.yaml | 6 ++++++ homeassistant/components/google/__init__.py | 3 +++ homeassistant/components/google/calendar.py | 3 +++ homeassistant/components/google/const.py | 1 + homeassistant/components/google/services.yaml | 6 ++++++ homeassistant/components/local_calendar/calendar.py | 1 + tests/components/google/test_calendar.py | 2 ++ tests/components/google/test_init.py | 5 +++++ tests/components/local_calendar/conftest.py | 4 +++- tests/components/local_calendar/test_calendar.py | 4 ++++ 11 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index d09a389ce82a..9af324465664 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -42,6 +42,7 @@ from .const import ( EVENT_IN, EVENT_IN_DAYS, EVENT_IN_WEEKS, + EVENT_LOCATION, EVENT_RECURRENCE_ID, EVENT_RECURRENCE_RANGE, EVENT_RRULE, @@ -176,6 +177,7 @@ CREATE_EVENT_SCHEMA = vol.All( { vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Optional(EVENT_LOCATION): cv.string, vol.Inclusive( EVENT_START_DATE, "dates", "Start and end dates must both be specified" ): cv.date, @@ -213,6 +215,7 @@ WEBSOCKET_EVENT_SCHEMA = vol.Schema( vol.Required(EVENT_END): vol.Any(cv.date, cv.datetime), vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION): cv.string, + vol.Optional(EVENT_LOCATION): cv.string, vol.Optional(EVENT_RRULE): _validate_rrule, }, _has_same_type(EVENT_START, EVENT_END), diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index dfe278a92d45..5d1a3ccf0f40 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -46,3 +46,9 @@ create_event: name: In description: Days or weeks that you want to create the event in. example: '{"days": 2} or {"weeks": 2}' + location: + name: Location + description: The location of the event. + example: "Conference Room - F123, Bldg. 002" + selector: + text: diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 934b34c126b2..25993760d807 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -43,6 +43,7 @@ from .const import ( EVENT_IN, EVENT_IN_DAYS, EVENT_IN_WEEKS, + EVENT_LOCATION, EVENT_START_DATE, EVENT_START_DATETIME, EVENT_SUMMARY, @@ -116,6 +117,7 @@ ADD_EVENT_SERVICE_SCHEMA = vol.All( vol.Required(EVENT_CALENDAR_ID): cv.string, vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Optional(EVENT_LOCATION, default=""): cv.string, vol.Inclusive( EVENT_START_DATE, "dates", "Start and end dates must both be specified" ): cv.date, @@ -290,6 +292,7 @@ async def async_setup_add_event_service( Event( summary=call.data[EVENT_SUMMARY], description=call.data[EVENT_DESCRIPTION], + location=call.data[EVENT_LOCATION], start=start, end=end, ), diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index d20155ad9090..1e1072940add 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -24,6 +24,7 @@ from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, EVENT_DESCRIPTION, EVENT_END, + EVENT_LOCATION, EVENT_RRULE, EVENT_START, EVENT_SUMMARY, @@ -507,6 +508,7 @@ class GoogleCalendarEntity( "start": start, "end": end, EVENT_DESCRIPTION: kwargs.get(EVENT_DESCRIPTION), + EVENT_LOCATION: kwargs.get(EVENT_LOCATION), } ) if rrule := kwargs.get(EVENT_RRULE): @@ -603,6 +605,7 @@ async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> Event( summary=call.data[EVENT_SUMMARY], description=call.data[EVENT_DESCRIPTION], + location=call.data[EVENT_LOCATION], start=start, end=end, ), diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index 6a2c1974f665..add98441e39f 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -38,6 +38,7 @@ EVENT_END_DATETIME = "end_date_time" EVENT_IN = "in" EVENT_IN_DAYS = "days" EVENT_IN_WEEKS = "weeks" +EVENT_LOCATION = "location" EVENT_START_DATE = "start_date" EVENT_START_DATETIME = "start_date_time" EVENT_SUMMARY = "summary" diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index a303ad7e18d2..e7eeef759475 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -103,3 +103,9 @@ create_event: example: '"days": 2 or "weeks": 2' selector: object: + location: + name: Location + description: The location of the event. Optional. + example: "Conference Room - F123, Bldg. 002" + selector: + text: diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 9cb6878ca552..2905e98caab9 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -196,4 +196,5 @@ def _get_calendar_event(event: Event) -> CalendarEvent: uid=event.uid, rrule=event.rrule.as_rrule_str() if event.rrule else None, recurrence_id=event.recurrence_id, + location=event.location, ) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 8b544a828e90..6d0ea7c51f01 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -888,6 +888,7 @@ async def test_websocket_create( assert aioclient_mock.mock_calls[0][2] == { "summary": "Bastille Day Party", "description": None, + "location": None, "start": { "dateTime": "1997-07-14T11:00:00-06:00", "timeZone": "America/Regina", @@ -931,6 +932,7 @@ async def test_websocket_create_all_day( assert aioclient_mock.mock_calls[0][2] == { "summary": "Bastille Day Party", "description": None, + "location": None, "start": { "date": "1997-07-14", }, diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index eac3bff58544..938dd2c28e73 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -42,6 +42,7 @@ HassApi = Callable[[], Awaitable[dict[str, Any]]] TEST_EVENT_SUMMARY = "Test Summary" TEST_EVENT_DESCRIPTION = "Test Description" +TEST_EVENT_LOCATION = "Test Location" def assert_state(actual: State | None, expected: State | None) -> None: @@ -93,6 +94,7 @@ def add_event_call_service( **params, "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, }, target=target, blocking=True, @@ -484,6 +486,7 @@ async def test_add_event_date_in_x( assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, "start": {"date": start_date.date().isoformat()}, "end": {"date": end_date.date().isoformat()}, } @@ -524,6 +527,7 @@ async def test_add_event_date( assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, "start": {"date": today.isoformat()}, "end": {"date": end_date.isoformat()}, } @@ -564,6 +568,7 @@ async def test_add_event_date_time( assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, + "location": TEST_EVENT_LOCATION, "start": { "dateTime": start_datetime.isoformat(timespec="seconds"), "timeZone": "America/Regina", diff --git a/tests/components/local_calendar/conftest.py b/tests/components/local_calendar/conftest.py index bde9c226bacf..b083bbac78ae 100644 --- a/tests/components/local_calendar/conftest.py +++ b/tests/components/local_calendar/conftest.py @@ -108,7 +108,9 @@ def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn: def event_fields(data: dict[str, str]) -> dict[str, str]: """Filter event API response to minimum fields.""" return { - k: data[k] for k in ["summary", "start", "end", "recurrence_id"] if data.get(k) + k: data[k] + for k in ["summary", "start", "end", "recurrence_id", "location"] + if data.get(k) } diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 319a352f62be..6bdb58cf65d0 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -873,6 +873,7 @@ async def test_create_event_service( "start_date_time": start_date_time, "end_date_time": end_date_time, "summary": "Bastille Day Party", + "location": "Test Location", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -886,6 +887,7 @@ async def test_create_event_service( "summary": "Bastille Day Party", "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + "location": "Test Location", } ] @@ -895,6 +897,7 @@ async def test_create_event_service( "summary": "Bastille Day Party", "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + "location": "Test Location", } ] @@ -909,5 +912,6 @@ async def test_create_event_service( "summary": "Bastille Day Party", "start": {"dateTime": "1997-07-14T11:00:00-06:00"}, "end": {"dateTime": "1997-07-14T22:00:00-06:00"}, + "location": "Test Location", } ] From 7cbe705ebb8176ed378da9c615d77e4deae46dca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Mar 2023 18:00:15 +0100 Subject: [PATCH 0755/1058] Update vehicle to 1.0.0 (#90189) --- homeassistant/components/rdw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 2cb660921c59..5ec3a6ae1903 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==0.4.0"] + "requirements": ["vehicle==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4b5a9999d7e..68cba63edf35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2574,7 +2574,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.0.0 # homeassistant.components.rdw -vehicle==0.4.0 +vehicle==1.0.0 # homeassistant.components.velbus velbus-aio==2023.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e31358bca92..a3e7cd970cdf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1832,7 +1832,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.0.0 # homeassistant.components.rdw -vehicle==0.4.0 +vehicle==1.0.0 # homeassistant.components.velbus velbus-aio==2023.2.0 From 7bceedfc95f10ffb2ff6a0f68b87908ea25a3620 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 07:05:35 -1000 Subject: [PATCH 0756/1058] Bump sqlalchemy to 2.0.7 (#90256) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 4f87c19ca7a5..c64c38fb7e5f 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["sqlalchemy==2.0.6", "fnvhash==0.1.0"] + "requirements": ["sqlalchemy==2.0.7", "fnvhash==0.1.0"] } diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 7513bbd8c7f9..2fed7d979489 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.6"] + "requirements": ["sqlalchemy==2.0.7"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0fcae3ec80ca..2f0cff131990 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,7 @@ pyudev==0.23.2 pyyaml==6.0 requests==2.28.2 scapy==2.5.0 -sqlalchemy==2.0.6 +sqlalchemy==2.0.7 typing-extensions>=4.5.0,<5.0 ulid-transform==0.5.1 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 68cba63edf35..cf804be5002a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2398,7 +2398,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.6 +sqlalchemy==2.0.7 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3e7cd970cdf..b0ee47f7714a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1707,7 +1707,7 @@ spotipy==2.22.1 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.6 +sqlalchemy==2.0.7 # homeassistant.components.srp_energy srpenergy==1.3.6 From cc337c4ff6dbb648714a5d2df6aae9c016296034 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 25 Mar 2023 18:09:33 +0100 Subject: [PATCH 0757/1058] Add Re-Auth to bmw_connected_drive (#90251) * Add Re-Auth to bmw_connected_drive * Always store refresh token to entry * Fix tests * Typo --------- Co-authored-by: rikroe --- .../bmw_connected_drive/config_flow.py | 42 ++++++++++++--- .../bmw_connected_drive/coordinator.py | 4 +- .../bmw_connected_drive/strings.json | 3 +- .../bmw_connected_drive/test_config_flow.py | 51 ++++++++++++++++++- 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 4f05794e311f..0cde37ba6b34 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -1,6 +1,7 @@ """Config flow for BMW ConnectedDrive integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from bimmer_connected.api.authentication import MyBMWAuthentication @@ -55,36 +56,61 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | 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: unique_id = f"{user_input[CONF_REGION]}-{user_input[CONF_USERNAME]}" - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() + if not self._reauth_entry: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() info = None try: info = await validate_input(self.hass, user_input) + entry_data = { + **user_input, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + } except CannotConnect: errors["base"] = "cannot_connect" if info: + if self._reauth_entry: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=entry_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=info["title"], - data={ - **user_input, - CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), - }, + data=entry_data, ) - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, self._reauth_entry.data if self._reauth_entry else {} ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 0f03505ff295..ae139d4c64a6 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -12,6 +12,7 @@ from httpx import HTTPError, HTTPStatusError, TimeoutException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN @@ -65,8 +66,9 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): 401, 403, ): - # Clear refresh token only on issues with authorization + # Clear refresh token only and trigger reauth self._update_config_entry_refresh_token(None) + raise ConfigEntryAuthFailed(str(err)) from err raise UpdateFailed(f"Error communicating with BMW API: {err}") from err if self.account.refresh_token != old_refresh_token: diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 3e93cccb8c6b..506175becd91 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -14,7 +14,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "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%]" } }, "options": { diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index e441be88263c..4db57ad30228 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -1,4 +1,5 @@ """Test the for the BMW Connected Drive config flow.""" +from copy import deepcopy from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication @@ -10,7 +11,7 @@ from homeassistant.components.bmw_connected_drive.const import ( CONF_READ_ONLY, CONF_REFRESH_TOKEN, ) -from homeassistant.const import CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from . import FIXTURE_CONFIG_ENTRY, FIXTURE_REFRESH_TOKEN, FIXTURE_USER_INPUT @@ -110,3 +111,51 @@ async def test_options_flow_implementation(hass: HomeAssistant) -> None: } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test the reauth form.""" + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication.login", + side_effect=login_sideeffect, + autospec=True, + ), patch( + "homeassistant.components.bmw_connected_drive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + wrong_password = "wrong" + + config_entry_with_wrong_password = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_with_wrong_password["data"][CONF_PASSWORD] = wrong_password + + config_entry = MockConfigEntry(**config_entry_with_wrong_password) + 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.data == config_entry_with_wrong_password["data"] + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + 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 config_entry.data == FIXTURE_COMPLETE_ENTRY + + assert len(mock_setup_entry.mock_calls) == 1 From fd460996ba8f618a828b847cd9b09082c5cb23dc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 09:21:05 -1000 Subject: [PATCH 0758/1058] Bump onvif-zeep-async to 1.2.2 to fix memory leak (#90216) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index db9f76189de8..4b998bdd6cd0 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.2.1", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.2.2", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf804be5002a..6c5103437643 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1263,7 +1263,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.2.1 +onvif-zeep-async==1.2.2 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0ee47f7714a..c4c70e7be22d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -935,7 +935,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.2.1 +onvif-zeep-async==1.2.2 # homeassistant.components.opengarage open-garage==0.2.0 From 668b2726fe599972aa9ca96b0c5e3d4f962c4839 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 10:12:48 -1000 Subject: [PATCH 0759/1058] Bump yalexs-ble to 2.1.4 (#90276) --- 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 7bbc6f042ef5..4e0522449563 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.2.7", "yalexs-ble==2.1.2"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.4"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index bb95a7038606..37f148a45ced 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.1.2"] + "requirements": ["yalexs-ble==2.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6c5103437643..3e6213bca149 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2671,7 +2671,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.2 +yalexs-ble==2.1.4 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4c70e7be22d..1a572d5ccbf5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.2 +yalexs-ble==2.1.4 # homeassistant.components.august yalexs==1.2.7 From 5c839e23679e78fc951d797180d66c2158113bf1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Mar 2023 23:06:03 +0100 Subject: [PATCH 0760/1058] Add entity name translations to Elgato (#89629) --- homeassistant/components/elgato/button.py | 4 +-- homeassistant/components/elgato/sensor.py | 10 +++--- homeassistant/components/elgato/strings.json | 35 +++++++++++++++++++ homeassistant/components/elgato/switch.py | 4 +-- .../elgato/snapshots/test_button.ambr | 4 +-- .../elgato/snapshots/test_sensor.ambr | 10 +++--- .../elgato/snapshots/test_switch.ambr | 4 +-- 7 files changed, 53 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 0dd602f1ecdc..97673a79b9a5 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -40,14 +40,14 @@ class ElgatoButtonEntityDescription( BUTTONS = [ ElgatoButtonEntityDescription( key="identify", - name="Identify", + translation_key="identify", icon="mdi:help", entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.identify(), ), ElgatoButtonEntityDescription( key="restart", - name="Restart", + translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_fn=lambda client: client.restart(), diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 2692cf10850a..371840de013a 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -45,7 +45,7 @@ class ElgatoSensorEntityDescription( SENSORS = [ ElgatoSensorEntityDescription( key="battery", - name="Battery", + translation_key="battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -56,7 +56,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="voltage", - name="Battery voltage", + translation_key="voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="input_charge_current", - name="Charging current", + translation_key="input_charge_current", entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -82,7 +82,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="charge_power", - name="Charging power", + translation_key="charge_power", entity_registry_enabled_default=False, device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, @@ -94,7 +94,7 @@ SENSORS = [ ), ElgatoSensorEntityDescription( key="input_charge_voltage", - name="Charging voltage", + translation_key="input_charge_voltage", entity_registry_enabled_default=False, device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index fc0007ac3016..c5fc016aeb90 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -21,5 +21,40 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "button": { + "identify": { + "name": "Identify" + }, + "restart": { + "name": "[%key:component::button::entity_component::restart::name%]" + } + }, + "sensor": { + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "charge_power": { + "name": "Charging power" + }, + "input_charge_current": { + "name": "Charging current" + }, + "input_charge_voltage": { + "name": "Charging voltage" + }, + "voltage": { + "name": "Battery voltage" + } + }, + "switch": { + "bypass": { + "name": "Studio mode" + }, + "energy_saving": { + "name": "Energy saving" + } + } } } diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index 00159099718f..78af3adfa539 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -39,7 +39,7 @@ class ElgatoSwitchEntityDescription( SWITCHES = [ ElgatoSwitchEntityDescription( key="bypass", - name="Studio mode", + translation_key="bypass", icon="mdi:battery-off-outline", entity_category=EntityCategory.CONFIG, has_fn=lambda x: x.battery is not None, @@ -48,7 +48,7 @@ SWITCHES = [ ), ElgatoSwitchEntityDescription( key="energy_saving", - name="Energy saving", + translation_key="energy_saving", icon="mdi:leaf", entity_category=EntityCategory.CONFIG, has_fn=lambda x: x.battery is not None, diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 900a3f316f55..cb420c486b47 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -37,7 +37,7 @@ 'original_name': 'Identify', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'identify', 'unique_id': 'GW24L1A02987_identify', 'unit_of_measurement': None, }) @@ -111,7 +111,7 @@ 'original_name': 'Restart', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'restart', 'unique_id': 'GW24L1A02987_restart', 'unit_of_measurement': None, }) diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index fa22ca1dfabe..35429b8a320c 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -44,7 +44,7 @@ 'original_name': 'Battery', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'battery', 'unique_id': 'GW24L1A02987_battery', 'unit_of_measurement': '%', }) @@ -128,7 +128,7 @@ 'original_name': 'Battery voltage', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'voltage', 'unique_id': 'GW24L1A02987_voltage', 'unit_of_measurement': , }) @@ -212,7 +212,7 @@ 'original_name': 'Charging current', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'input_charge_current', 'unique_id': 'GW24L1A02987_input_charge_current', 'unit_of_measurement': , }) @@ -293,7 +293,7 @@ 'original_name': 'Charging power', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'charge_power', 'unique_id': 'GW24L1A02987_charge_power', 'unit_of_measurement': , }) @@ -377,7 +377,7 @@ 'original_name': 'Charging voltage', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'input_charge_voltage', 'unique_id': 'GW24L1A02987_input_charge_voltage', 'unit_of_measurement': , }) diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index 02f32d22f96b..dcba00c0a9e0 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -37,7 +37,7 @@ 'original_name': 'Energy saving', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'energy_saving', 'unique_id': 'GW24L1A02987_energy_saving', 'unit_of_measurement': None, }) @@ -111,7 +111,7 @@ 'original_name': 'Studio mode', 'platform': 'elgato', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'bypass', 'unique_id': 'GW24L1A02987_bypass', 'unit_of_measurement': None, }) From 89d00ac733588e90ef9cc25730eebf19f1d3e7d0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 25 Mar 2023 23:31:01 +0100 Subject: [PATCH 0761/1058] Fix default ipv6 resolver (#90269) --- homeassistant/components/dnsip/const.py | 2 +- tests/components/dnsip/test_config_flow.py | 14 +++++++------- tests/components/dnsip/test_init.py | 2 +- tests/components/dnsip/test_sensor.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/dnsip/const.py b/homeassistant/components/dnsip/const.py index a4f2c2fee2dd..56215d3d9a60 100644 --- a/homeassistant/components/dnsip/const.py +++ b/homeassistant/components/dnsip/const.py @@ -15,4 +15,4 @@ DEFAULT_HOSTNAME = "myip.opendns.com" DEFAULT_IPV6 = False DEFAULT_NAME = "myip" DEFAULT_RESOLVER = "208.67.222.222" -DEFAULT_RESOLVER_IPV6 = "2620:0:ccc::2" +DEFAULT_RESOLVER_IPV6 = "2620:119:53::53" diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py index 990fd4df1596..7e219326ee98 100644 --- a/tests/components/dnsip/test_config_flow.py +++ b/tests/components/dnsip/test_config_flow.py @@ -60,7 +60,7 @@ async def test_form(hass: HomeAssistant) -> None: } assert result2["options"] == { "resolver": "208.67.222.222", - "resolver_ipv6": "2620:0:ccc::2", + "resolver_ipv6": "2620:119:53::53", } assert len(mock_setup_entry.mock_calls) == 1 @@ -87,7 +87,7 @@ async def test_form_adv(hass: HomeAssistant) -> None: { CONF_HOSTNAME: "home-assistant.io", CONF_RESOLVER: "8.8.8.8", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, ) await hass.async_block_till_done() @@ -102,7 +102,7 @@ async def test_form_adv(hass: HomeAssistant) -> None: } assert result2["options"] == { "resolver": "8.8.8.8", - "resolver_ipv6": "2620:0:ccc::2", + "resolver_ipv6": "2620:119:53::53", } assert len(mock_setup_entry.mock_calls) == 1 @@ -143,7 +143,7 @@ async def test_flow_already_exist(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", }, unique_id="home-assistant.io", ).add_to_hass(hass) @@ -185,7 +185,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", }, ) entry.add_to_hass(hass) @@ -227,7 +227,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOSTNAME: "home-assistant.io", CONF_NAME: "home-assistant.io", CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", CONF_IPV4: True, CONF_IPV6: False, }, @@ -235,7 +235,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOSTNAME: "home-assistant.io", CONF_NAME: "home-assistant.io", CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::5", CONF_IPV4: False, CONF_IPV6: True, }, diff --git a/tests/components/dnsip/test_init.py b/tests/components/dnsip/test_init.py index 1c8cf04c7837..2869f13ca87f 100644 --- a/tests/components/dnsip/test_init.py +++ b/tests/components/dnsip/test_init.py @@ -34,7 +34,7 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, entry_id="1", unique_id="home-assistant.io", diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index f44d58d21258..75e5f5ebf88e 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -37,7 +37,7 @@ async def test_sensor(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, entry_id="1", unique_id="home-assistant.io", @@ -71,7 +71,7 @@ async def test_sensor_no_response(hass: HomeAssistant) -> None: }, options={ CONF_RESOLVER: "208.67.222.222", - CONF_RESOLVER_IPV6: "2620:0:ccc::2", + CONF_RESOLVER_IPV6: "2620:119:53::53", }, entry_id="1", unique_id="home-assistant.io", From 7f6406127ead60ebeecfab5f3921a1c5556fbd42 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 25 Mar 2023 23:43:44 +0100 Subject: [PATCH 0762/1058] Remove platform yaml radiotherm (#90284) --- .../components/radiotherm/climate.py | 70 +------------------ .../components/radiotherm/config_flow.py | 19 ----- .../components/radiotherm/strings.json | 6 -- .../components/radiotherm/test_config_flow.py | 39 +---------- 4 files changed, 3 insertions(+), 131 deletions(-) diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index a800061b5836..2c71eac01933 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -1,17 +1,14 @@ """Support for Radio Thermostat wifi-enabled home thermostats.""" from __future__ import annotations -import logging from typing import Any import radiotherm -import voluptuous as vol from homeassistant.components.climate import ( FAN_AUTO, FAN_OFF, FAN_ON, - PLATFORM_SCHEMA, PRESET_AWAY, PRESET_HOME, ClimateEntity, @@ -19,25 +16,15 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - PRECISION_HALVES, - UnitOfTemperature, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, 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.typing import ConfigType, DiscoveryInfoType from . import DOMAIN from .coordinator import RadioThermUpdateCoordinator from .entity import RadioThermostatEntity -_LOGGER = logging.getLogger(__name__) - ATTR_FAN_ACTION = "fan_action" PRESET_HOLIDAY = "holiday" @@ -102,14 +89,6 @@ def round_temp(temperature): return round(temperature * 2.0) / 2.0 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean, - } -) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -120,51 +99,6 @@ async def async_setup_entry( async_add_entities([RadioThermostat(coordinator)]) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Radio Thermostat.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - _LOGGER.warning( - "Configuration of the Radio Thermostat climate platform in YAML is deprecated" - " and will be removed in Home Assistant 2022.9; Your existing configuration has" - " been imported into the UI automatically and can be safely removed from your" - " configuration.yaml file" - ) - - hosts: list[str] = [] - if CONF_HOST in config: - hosts = config[CONF_HOST] - else: - hosts.append( - await hass.async_add_executor_job(radiotherm.discover.discover_address) - ) - - if not hosts: - _LOGGER.error("No Radiotherm Thermostats detected") - return - - for host in hosts: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: host}, - ) - ) - - class RadioThermostat(RadioThermostatEntity, ClimateEntity): """Representation of a Radio Thermostat.""" diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index a3acc2e43894..ca488ade461e 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -83,25 +83,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders=placeholders, ) - async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: - """Import from yaml.""" - host = import_info[CONF_HOST] - self._async_abort_entries_match({CONF_HOST: host}) - _LOGGER.debug("Importing entry for host: %s", host) - try: - init_data = await validate_connection(self.hass, host) - except CannotConnect as ex: - _LOGGER.debug("Importing failed for %s", host, exc_info=ex) - return self.async_abort(reason="cannot_connect") - await self.async_set_unique_id(init_data.mac, raise_on_progress=False) - self._abort_if_unique_id_configured( - updates={CONF_HOST: host}, reload_on_update=False - ) - return self.async_create_entry( - title=init_data.name, - data={CONF_HOST: import_info[CONF_HOST]}, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index f0b31cdb4d61..21f53d72bfa5 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -19,12 +19,6 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, - "issues": { - "deprecated_yaml": { - "title": "The Radio Thermostat YAML configuration is being removed", - "description": "Configuring the Radio Thermostat climate platform using YAML is being removed in Home Assistant 2022.9.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "options": { "step": { "init": { diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py index 053bca0aa6b7..5625a50a4c01 100644 --- a/tests/components/radiotherm/test_config_flow.py +++ b/tests/components/radiotherm/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Radio Thermostat config flow.""" -import socket + from unittest.mock import MagicMock, patch from radiotherm import CommonThermostat @@ -98,43 +98,6 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_HOST: "cannot_connect"} -async def test_import(hass: HomeAssistant) -> None: - """Test we get can import from yaml.""" - with patch( - "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", - return_value=_mock_radiotherm(), - ), patch( - "homeassistant.components.radiotherm.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={CONF_HOST: "1.2.3.4"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "My Name" - assert result["data"] == {CONF_HOST: "1.2.3.4"} - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_cannot_connect(hass: HomeAssistant) -> None: - """Test we abort if we cannot connect on import from yaml.""" - with patch( - "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", - side_effect=socket.timeout, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.2.3.4"}, - ) - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - async def test_dhcp_can_confirm(hass: HomeAssistant) -> None: """Test DHCP discovery flow can confirm right away.""" From 92beb48a415333d793e2bc1bdefe987876ef8230 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 00:24:43 +0100 Subject: [PATCH 0763/1058] Add sensor platform to Sun (#81045) * Sun sensor * remove extra attr * Add tests * Add back attributes * position sensors disabled default * entity id * unique id * test init to attributes * Fix test init * Fix test sensor * test unique id * uom * remove rising * Remove not needed uom property * Fix reload issue * degree --- homeassistant/components/sun/__init__.py | 13 ++- homeassistant/components/sun/sensor.py | 133 +++++++++++++++++++++++ tests/components/sun/test_init.py | 7 +- tests/components/sun/test_sensor.py | 101 +++++++++++++++++ 4 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/sun/sensor.py create mode 100644 tests/components/sun/test_sensor.py diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 65836e0c619d..a43bf4fd8082 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( EVENT_CORE_CONFIG_UPDATE, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, + Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import event @@ -97,15 +98,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # we will create entities before firing EVENT_COMPONENT_LOADED await async_process_integration_platform_for_component(hass, DOMAIN) hass.data[DOMAIN] = Sun(hass) + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - sun = hass.data.pop(DOMAIN) - sun.remove_listeners() - hass.states.async_remove(sun.entity_id) - return True + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, [Platform.SENSOR] + ): + sun: Sun = hass.data.pop(DOMAIN) + sun.remove_listeners() + hass.states.async_remove(sun.entity_id) + return unload_ok class Sun(Entity): diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py new file mode 100644 index 000000000000..527ccc4069fa --- /dev/null +++ b/homeassistant/components/sun/sensor.py @@ -0,0 +1,133 @@ +"""Sensor platform for Sun integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import Sun +from .const import DOMAIN + +ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" + + +@dataclass +class SunEntityDescriptionMixin: + """Mixin for required Sun base description keys.""" + + value_fn: Callable[[Sun], StateType | datetime] + + +@dataclass +class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): + """Describes Sun sensor entity.""" + + +SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( + SunSensorEntityDescription( + key="next_dawn", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next dawn", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dawn, + ), + SunSensorEntityDescription( + key="next_dusk", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next dusk", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_dusk, + ), + SunSensorEntityDescription( + key="next_midnight", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next midnight", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_midnight, + ), + SunSensorEntityDescription( + key="next_noon", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next noon", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_noon, + ), + SunSensorEntityDescription( + key="next_rising", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next rising", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_rising, + ), + SunSensorEntityDescription( + key="next_setting", + device_class=SensorDeviceClass.TIMESTAMP, + name="Next setting", + icon="mdi:sun-clock", + value_fn=lambda data: data.next_setting, + ), + SunSensorEntityDescription( + key="solar_elevation", + name="Solar elevation", + icon="mdi:theme-light-dark", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_elevation, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), + SunSensorEntityDescription( + key="solar_azimuth", + name="Solar azimuth", + icon="mdi:sun-angle", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.solar_azimuth, + entity_registry_enabled_default=False, + native_unit_of_measurement=DEGREE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sun sensor platform.""" + + sun: Sun = hass.data[DOMAIN] + + async_add_entities( + [SunSensor(sun, description, entry.entry_id) for description in SENSOR_TYPES] + ) + + +class SunSensor(SensorEntity): + """Representation of a Sun Sensor.""" + + entity_description: SunSensorEntityDescription + + def __init__( + self, sun: Sun, entity_description: SunSensorEntityDescription, entry_id: str + ) -> None: + """Initiate Sun Sensor.""" + self.entity_description = entity_description + self.entity_id = ENTITY_ID_SENSOR_FORMAT.format(entity_description.key) + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self.sun = sun + + @property + def native_value(self) -> StateType | datetime: + """Return value of sensor.""" + state = self.entity_description.value_fn(self.sun) + return state diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py index 2795330bf7be..fef9bd4e0491 100644 --- a/tests/components/sun/test_init.py +++ b/tests/components/sun/test_init.py @@ -5,10 +5,9 @@ from unittest.mock import patch from freezegun import freeze_time import pytest -import homeassistant.components.sun as sun +from homeassistant.components import sun from homeassistant.const import EVENT_STATE_CHANGED -import homeassistant.core as ha -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -196,7 +195,7 @@ async def test_state_change_count(hass: HomeAssistant) -> None: events = [] - @ha.callback + @callback def state_change_listener(event): if event.data.get("entity_id") == "sun.sun": events.append(event) diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py new file mode 100644 index 000000000000..13f4fd0d62b1 --- /dev/null +++ b/tests/components/sun/test_sensor.py @@ -0,0 +1,101 @@ +"""The tests for the Sun sensor platform.""" +from datetime import datetime, timedelta + +from astral import LocationInfo +import astral.sun +from freezegun import freeze_time + +from homeassistant.components import sun +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + + +async def test_setting_rising(hass: HomeAssistant) -> None: + """Test retrieving sun setting and rising.""" + utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(utc_now): + await async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {}}) + + await hass.async_block_till_done() + + utc_today = utc_now.date() + + location = LocationInfo( + latitude=hass.config.latitude, longitude=hass.config.longitude + ) + + mod = -1 + while True: + next_dawn = astral.sun.dawn( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_dawn > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_dusk = astral.sun.dusk( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_dusk > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_midnight = astral.sun.midnight( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_midnight > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_noon = astral.sun.noon( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_noon > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_rising = astral.sun.sunrise( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_rising > utc_now: + break + mod += 1 + + mod = -1 + while True: + next_setting = astral.sun.sunset( + location.observer, date=utc_today + timedelta(days=mod) + ) + if next_setting > utc_now: + break + mod += 1 + + state1 = hass.states.get("sensor.sun_next_dawn") + state2 = hass.states.get("sensor.sun_next_dusk") + state3 = hass.states.get("sensor.sun_next_midnight") + state4 = hass.states.get("sensor.sun_next_noon") + state5 = hass.states.get("sensor.sun_next_rising") + state6 = hass.states.get("sensor.sun_next_setting") + assert next_dawn.replace(microsecond=0) == dt_util.parse_datetime(state1.state) + assert next_dusk.replace(microsecond=0) == dt_util.parse_datetime(state2.state) + assert next_midnight.replace(microsecond=0) == dt_util.parse_datetime(state3.state) + assert next_noon.replace(microsecond=0) == dt_util.parse_datetime(state4.state) + assert next_rising.replace(microsecond=0) == dt_util.parse_datetime(state5.state) + assert next_setting.replace(microsecond=0) == dt_util.parse_datetime(state6.state) + + entry_ids = hass.config_entries.async_entries("sun") + + entity_reg = er.async_get(hass) + entity = entity_reg.async_get("sensor.sun_next_dawn") + + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dawn" From 255f12ec05d7e70f9617df78af1a2153f08c32dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 15:48:03 -1000 Subject: [PATCH 0764/1058] Bump bleak-retry-connector to 3.0.2 (#90279) changelog: https://github.com/Bluetooth-Devices/bleak-retry-connector/compare/v3.0.1...v3.0.2 --- 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 f6cbe5b3e54c..87a584f0bef3 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.20.0", - "bleak-retry-connector==3.0.1", + "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", "bluetooth-data-tools==0.3.1", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2f0cff131990..4af85540de0d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ atomicwrites-homeassistant==1.4.1 attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==3.0.1 +bleak-retry-connector==3.0.2 bleak==0.20.0 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index 3e6213bca149..c1a05be4e57d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -431,7 +431,7 @@ bimmer_connected==0.13.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.0.1 +bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth bleak==0.20.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a572d5ccbf5..f2c15b7771df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,7 +361,7 @@ bellows==0.34.10 bimmer_connected==0.13.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.0.1 +bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth bleak==0.20.0 From 53726cb4a194f61d8a9284ce7b64ff6b6743e6c6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 04:02:10 +0200 Subject: [PATCH 0765/1058] Remove Magicseaweed (#90277) --- .coveragerc | 1 - .../components/magicseaweed/__init__.py | 1 - .../components/magicseaweed/manifest.json | 9 - .../components/magicseaweed/sensor.py | 227 ------------------ .../components/magicseaweed/strings.json | 8 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - 7 files changed, 255 deletions(-) delete mode 100644 homeassistant/components/magicseaweed/__init__.py delete mode 100644 homeassistant/components/magicseaweed/manifest.json delete mode 100644 homeassistant/components/magicseaweed/sensor.py delete mode 100644 homeassistant/components/magicseaweed/strings.json diff --git a/.coveragerc b/.coveragerc index 24f324313ae2..f3dbc3479195 100644 --- a/.coveragerc +++ b/.coveragerc @@ -678,7 +678,6 @@ omit = homeassistant/components/lyric/api.py homeassistant/components/lyric/climate.py homeassistant/components/lyric/sensor.py - homeassistant/components/magicseaweed/sensor.py homeassistant/components/mailgun/notify.py homeassistant/components/map/* homeassistant/components/mastodon/notify.py diff --git a/homeassistant/components/magicseaweed/__init__.py b/homeassistant/components/magicseaweed/__init__.py deleted file mode 100644 index 848d02967fe3..000000000000 --- a/homeassistant/components/magicseaweed/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The magicseaweed component.""" diff --git a/homeassistant/components/magicseaweed/manifest.json b/homeassistant/components/magicseaweed/manifest.json deleted file mode 100644 index 4858e6be4f5f..000000000000 --- a/homeassistant/components/magicseaweed/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "magicseaweed", - "name": "Magicseaweed", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/magicseaweed", - "iot_class": "cloud_polling", - "loggers": ["magicseaweed"], - "requirements": ["magicseaweed==1.0.3"] -} diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py deleted file mode 100644 index aa59553ef81c..000000000000 --- a/homeassistant/components/magicseaweed/sensor.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Support for magicseaweed data from magicseaweed.com.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -import magicseaweed -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle -import homeassistant.util.dt as dt_util -from homeassistant.util.unit_system import METRIC_SYSTEM - -_LOGGER = logging.getLogger(__name__) - -CONF_HOURS = "hours" -CONF_SPOT_ID = "spot_id" -CONF_UNITS = "units" - -DEFAULT_UNIT = "us" -DEFAULT_NAME = "MSW" - -ICON = "mdi:waves" - -HOURS = ["12AM", "3AM", "6AM", "9AM", "12PM", "3PM", "6PM", "9PM"] - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="max_breaking_swell", - name="Max", - ), - SensorEntityDescription( - key="min_breaking_swell", - name="Min", - ), - SensorEntityDescription( - key="swell_forecast", - name="Forecast", - ), -) - -SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] - - -UNITS = ["eu", "uk", "us"] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, [vol.In(SENSOR_KEYS)] - ), - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_SPOT_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_HOURS, default=None): vol.All( - cv.ensure_list, [vol.In(HOURS)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNITS): vol.In(UNITS), - } -) - -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Magicseaweed sensor.""" - create_issue( - hass, - "magicseaweed", - "pending_removal", - breaks_in_ha_version="2023.3.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="pending_removal", - ) - _LOGGER.warning( - "The Magicseaweed integration is deprecated" - " and will be removed in Home Assistant 2023.3" - ) - - name = config.get(CONF_NAME) - spot_id = config[CONF_SPOT_ID] - api_key = config[CONF_API_KEY] - hours = config.get(CONF_HOURS) - - if CONF_UNITS in config: - units = config.get(CONF_UNITS) - elif hass.config.units is METRIC_SYSTEM: - units = UNITS[0] - else: - units = UNITS[2] - - forecast_data = MagicSeaweedData(api_key=api_key, spot_id=spot_id, units=units) - forecast_data.update() - - # If connection failed don't setup platform. - if forecast_data.currently is None or forecast_data.hourly is None: - return - - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - sensors = [ - MagicSeaweedSensor(forecast_data, name, units, description) - for description in SENSOR_TYPES - if description.key in monitored_conditions - ] - if hours is not None: - sensors.extend( - [ - MagicSeaweedSensor(forecast_data, name, units, description, hour) - for description in SENSOR_TYPES - if description.key in monitored_conditions - and "forecast" not in description.key - for hour in hours - ] - ) - add_entities(sensors, True) - - -class MagicSeaweedSensor(SensorEntity): - """Implementation of a MagicSeaweed sensor.""" - - _attr_attribution = "Data provided by magicseaweed.com" - _attr_icon = ICON - - def __init__( - self, - forecast_data, - name, - unit_system, - description: SensorEntityDescription, - hour=None, - ) -> None: - """Initialize the sensor.""" - self.entity_description = description - self.client_name = name - self.data = forecast_data - self.hour = hour - self._unit_system = unit_system - - if hour is None and "forecast" in description.key: - self._attr_name = f"{name} {description.name}" - elif hour is None: - self._attr_name = f"Current {name} {description.name}" - else: - self._attr_name = f"{hour} {name} {description.name}" - - self._attr_extra_state_attributes = {} - - @property - def unit_system(self): - """Return the unit system of this entity.""" - return self._unit_system - - def update(self) -> None: - """Get the latest data from Magicseaweed and updates the states.""" - self.data.update() - if self.hour is None: - forecast = self.data.currently - else: - forecast = self.data.hourly[self.hour] - - self._attr_native_unit_of_measurement = forecast.swell_unit - sensor_type = self.entity_description.key - if sensor_type == "min_breaking_swell": - self._attr_native_value = forecast.swell_minBreakingHeight - elif sensor_type == "max_breaking_swell": - self._attr_native_value = forecast.swell_maxBreakingHeight - elif sensor_type == "swell_forecast": - summary = ( - f"{forecast.swell_minBreakingHeight} -" - f" {forecast.swell_maxBreakingHeight}" - ) - self._attr_native_value = summary - if self.hour is None: - for hour, data in self.data.hourly.items(): - occurs = hour - hr_summary = ( - f"{data.swell_minBreakingHeight} -" - f" {data.swell_maxBreakingHeight} {data.swell_unit}" - ) - self._attr_extra_state_attributes[occurs] = hr_summary - - if sensor_type != "swell_forecast": - self._attr_extra_state_attributes.update(forecast.attrs) - - -class MagicSeaweedData: - """Get the latest data from MagicSeaweed.""" - - def __init__(self, api_key, spot_id, units): - """Initialize the data object.""" - self._msw = magicseaweed.MSW_Forecast(api_key, spot_id, None, units) - self.currently = None - self.hourly = {} - - # Apply throttling to methods using configured interval - self.update = Throttle(MIN_TIME_BETWEEN_UPDATES)(self._update) - - def _update(self): - """Get the latest data from MagicSeaweed.""" - try: - forecasts = self._msw.get_future() - self.currently = forecasts.data[0] - for forecast in forecasts.data[:8]: - hour = dt_util.utc_from_timestamp(forecast.localTimestamp).strftime( - "%-I%p" - ) - self.hourly[hour] = forecast - except ConnectionError: - _LOGGER.error("Unable to retrieve data from Magicseaweed") diff --git a/homeassistant/components/magicseaweed/strings.json b/homeassistant/components/magicseaweed/strings.json deleted file mode 100644 index 0aa8a584190e..000000000000 --- a/homeassistant/components/magicseaweed/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "pending_removal": { - "title": "The Magicseaweed integration is being removed", - "description": "The Magicseaweed integration is pending removal from Home Assistant and will no longer be available as of Home Assistant 2023.3.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index efd1899c5b0e..843b8ed006d7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3078,12 +3078,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "magicseaweed": { - "name": "Magicseaweed", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "mailgun": { "name": "Mailgun", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c1a05be4e57d..5da7cafec3c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1092,9 +1092,6 @@ lxml==4.9.1 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 -# homeassistant.components.magicseaweed -magicseaweed==1.0.3 - # homeassistant.components.matrix matrix-client==0.4.0 From 5f59bab9ecb75fd5686899f4d9cedd1c3d63168f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 16:29:26 -1000 Subject: [PATCH 0766/1058] Bump bleak to 0.20.1 (#90282) Co-authored-by: Charles Garwood --- 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 87a584f0bef3..31b9bdb5d5e6 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.0", + "bleak==0.20.1", "bleak-retry-connector==3.0.2", "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.0.3", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4af85540de0d..c91ab060b5c6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.0.2 -bleak==0.20.0 +bleak==0.20.1 bluetooth-adapters==0.15.3 bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 diff --git a/requirements_all.txt b/requirements_all.txt index 5da7cafec3c7..8517577dd141 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -434,7 +434,7 @@ bizkaibus==0.1.1 bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.0 +bleak==0.20.1 # homeassistant.components.blebox blebox_uniapi==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2c15b7771df..2cd15e9af9ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -364,7 +364,7 @@ bimmer_connected==0.13.0 bleak-retry-connector==3.0.2 # homeassistant.components.bluetooth -bleak==0.20.0 +bleak==0.20.1 # homeassistant.components.blebox blebox_uniapi==2.1.4 From bd08d888123e17bb1b21550267355003613b26ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 17:27:35 -1000 Subject: [PATCH 0767/1058] Bump yalexs-ble to 2.1.5 (#90287) Bump yalexs-ble 2.1.5 Some of the lever locks need a bit longer debounce time since they still report stale state for up to 6s changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.4...v2.1.5 --- 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 4e0522449563..3db09bdc34a4 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.2.7", "yalexs-ble==2.1.4"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.5"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 37f148a45ced..ba314ecf2a45 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.1.4"] + "requirements": ["yalexs-ble==2.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8517577dd141..66edf5348056 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.4 +yalexs-ble==2.1.5 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cd15e9af9ed..bf5998ed50fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.4 +yalexs-ble==2.1.5 # homeassistant.components.august yalexs==1.2.7 From 0b8fb36a7e6183309b621ef4f8bf00e7569e80ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 17:28:38 -1000 Subject: [PATCH 0768/1058] Fix onvif binary sensors (#90202) * Fix httpx client creating a new ssl context with each client While working on https://github.com/home-assistant/core/issues/83524 it was discovered that each new httpx client creates a new ssl context https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_transports/default.py#L261 If an ssl context is passed in creating a new one is avoided here https://github.com/encode/httpx/blob/f1157dbc4102ac8e227a0a0bb12a877f592eff58/httpx/_config.py#L110 This change makes httpx ssl no-verify behavior match aiohttp ssl no-verify behavior https://github.com/aio-libs/aiohttp/blob/6da04694fd87a39af9c3856048c9ff23ca815f88/aiohttp/connector.py#L892 aiohttp solved this by wrapping the code that generates the ssl context in an lru_cache * compact * Fix onvif binary sensors fixes #83524 needs https://github.com/hunterjm/python-onvif-zeep-async/pull/9 first to avoid recreating the memory leak * Fix memory leak in onvif Work around until https://github.com/hunterjm/python-onvif-zeep-async/pull/9 followup to https://github.com/home-assistant/core/pull/83006 * move check * onvif-zeep-async 1.2.2 * fix unloading --- homeassistant/components/onvif/event.py | 58 +++++++++++++++---------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 54c5b3b007bb..84d75bf80482 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -27,6 +27,13 @@ SUBSCRIPTION_ERRORS = ( ) +def _stringify_onvif_error(error: Exception) -> str: + """Stringify ONVIF error.""" + if isinstance(error, Fault): + return error.message or str(error) or "Device sent empty error" + return str(error) + + class EventManager: """ONVIF Event Manager.""" @@ -79,30 +86,30 @@ class EventManager: async def async_start(self) -> bool: """Start polling events.""" - if await self.device.create_pullpoint_subscription(): - # Create subscription manager - self._subscription = self.device.create_subscription_service( - "PullPointSubscription" - ) + if not await self.device.create_pullpoint_subscription(): + return False - # Renew immediately - await self.async_renew() + # Create subscription manager + self._subscription = self.device.create_subscription_service( + "PullPointSubscription" + ) - # Initialize events - pullpoint = self.device.create_pullpoint_service() - with suppress(*SUBSCRIPTION_ERRORS): - await pullpoint.SetSynchronizationPoint() - response = await pullpoint.PullMessages( - {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=5)} - ) + # Renew immediately + await self.async_renew() - # Parse event initialization - await self.async_parse_messages(response.NotificationMessage) + # Initialize events + pullpoint = self.device.create_pullpoint_service() + with suppress(*SUBSCRIPTION_ERRORS): + await pullpoint.SetSynchronizationPoint() + response = await pullpoint.PullMessages( + {"MessageLimit": 100, "Timeout": dt.timedelta(seconds=5)} + ) - self.started = True - return True + # Parse event initialization + await self.async_parse_messages(response.NotificationMessage) - return False + self.started = True + return True async def async_stop(self) -> None: """Unsubscribe from events.""" @@ -112,7 +119,8 @@ class EventManager: if not self._subscription: return - await self._subscription.Unsubscribe() + with suppress(*SUBSCRIPTION_ERRORS): + await self._subscription.Unsubscribe() self._subscription = None async def async_restart(self, _now: dt.datetime | None = None) -> None: @@ -148,7 +156,7 @@ class EventManager: "Retrying later: %s" ), self.unique_id, - err, + _stringify_onvif_error(err), ) if not restarted: @@ -170,7 +178,11 @@ class EventManager: .isoformat(timespec="seconds") .replace("+00:00", "Z") ) - await self._subscription.Renew(termination_time) + with suppress(*SUBSCRIPTION_ERRORS): + # The first time we renew, we may get a Fault error so we + # suppress it. The subscription will be restarted in + # async_restart later. + await self._subscription.Renew(termination_time) def async_schedule_pull(self) -> None: """Schedule async_pull_messages to run.""" @@ -203,7 +215,7 @@ class EventManager: " '%s': %s" ), self.unique_id, - err, + _stringify_onvif_error(err), ) # Treat errors as if the camera restarted. Assume that the pullpoint # subscription is no longer valid. From 40131d811c1f0af9040a9f46de25b47a8bce576c Mon Sep 17 00:00:00 2001 From: Felix Rotthowe Date: Sun, 26 Mar 2023 09:35:49 +0200 Subject: [PATCH 0769/1058] Handle Livisi TokenExpiredException (#90258) * reauth * Request new Token on TokenExpiredException * relogin using stored auth data * fix imports * import formatting --- homeassistant/components/livisi/coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index f745a66e827e..56e928307c17 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -6,6 +6,7 @@ from typing import Any from aiohttp import ClientConnectorError from aiolivisi import AioLivisi, LivisiEvent, Websocket +from aiolivisi.errors import TokenExpiredException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -55,8 +56,11 @@ class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Get device configuration from LIVISI.""" try: return await self.async_get_devices() + except TokenExpiredException: + await self.aiolivisi.async_set_token(self.aiolivisi.livisi_connection_data) + return await self.async_get_devices() except ClientConnectorError as exc: - raise UpdateFailed("Failed to get LIVISI the devices") from exc + raise UpdateFailed("Failed to get livisi devices from controller") from exc def _async_dispatcher_send(self, event: str, source: str, data: Any) -> None: if data is not None: From b3f3f234c69cf276f0cea34150aa3726e5d3b77d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 09:40:07 +0200 Subject: [PATCH 0770/1058] Remove pushbullet platform yaml import (#90285) Depr pushbullet yaml --- .../components/pushbullet/config_flow.py | 5 -- homeassistant/components/pushbullet/notify.py | 30 ++---------- homeassistant/components/pushbullet/sensor.py | 49 ++----------------- .../components/pushbullet/strings.json | 6 --- .../components/pushbullet/test_config_flow.py | 13 ----- 5 files changed, 6 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/pushbullet/config_flow.py b/homeassistant/components/pushbullet/config_flow.py index e6259fa8ceea..1eca2bd890b4 100644 --- a/homeassistant/components/pushbullet/config_flow.py +++ b/homeassistant/components/pushbullet/config_flow.py @@ -24,11 +24,6 @@ CONFIG_SCHEMA = vol.Schema( class PushBulletConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for pushbullet integration.""" - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Handle import from config.""" - import_config[CONF_NAME] = import_config.get(CONF_NAME, DEFAULT_NAME) - return await self.async_step_user(import_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index fcc9d00dc7a4..1cc851bdb991 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import mimetypes -from typing import Any +from typing import TYPE_CHECKING, Any from pushbullet import PushBullet, PushError from pushbullet.channel import Channel @@ -15,23 +15,16 @@ from homeassistant.components.notify import ( ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) - async def async_get_service( hass: HomeAssistant, @@ -39,25 +32,8 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> PushBulletNotificationService | None: """Get the Pushbullet notification service.""" - if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - return None - + if TYPE_CHECKING: + assert discovery_info is not None pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet return PushBulletNotificationService(hass, pushbullet) diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index aef97991c664..b61469f6b2a8 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -1,23 +1,14 @@ """Pushbullet platform for sensor component.""" from __future__ import annotations -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorEntity, - SensorEntityDescription, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import 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 .api import PushBulletNotificationProvider from .const import DATA_UPDATED, DOMAIN @@ -75,40 +66,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=["title", "body"]): vol.All( - cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_KEYS)] - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Pushbullet Sensor platform.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.2.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/pushbullet/strings.json b/homeassistant/components/pushbullet/strings.json index 92d22d117dcb..a6571ae7bf0f 100644 --- a/homeassistant/components/pushbullet/strings.json +++ b/homeassistant/components/pushbullet/strings.json @@ -15,11 +15,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "title": "The Pushbullet YAML configuration is being removed", - "description": "Configuring Pushbullet using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Pushbullet YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/pushbullet/test_config_flow.py b/tests/components/pushbullet/test_config_flow.py index a19c424c8be8..f250c22c4433 100644 --- a/tests/components/pushbullet/test_config_flow.py +++ b/tests/components/pushbullet/test_config_flow.py @@ -119,16 +119,3 @@ async def test_flow_conn_error(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} - - -async def test_import(hass: HomeAssistant, requests_mock_fixture) -> None: - """Test user initialized flow with unreachable server.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "pushbullet" - assert result["data"] == MOCK_CONFIG From e8f3b9c09a58972f3f2af83b0c85f8f001db6523 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 26 Mar 2023 09:42:38 +0200 Subject: [PATCH 0771/1058] Remove Volvooncall integration yaml import (#90288) Depr yaml import --- .../components/volvooncall/__init__.py | 73 +------------------ .../components/volvooncall/config_flow.py | 4 - .../components/volvooncall/strings.json | 6 -- .../volvooncall/test_config_flow.py | 39 ---------- 4 files changed, 1 insertion(+), 121 deletions(-) diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index b6d97dea216e..ab4fa781110f 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -4,28 +4,21 @@ import logging from aiohttp.client_exceptions import ClientResponseError import async_timeout -import voluptuous as vol from volvooncall import Connection from volvooncall.dashboard import Instrument -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, CONF_PASSWORD, CONF_REGION, - CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM, CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -35,11 +28,9 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( CONF_MUTABLE, CONF_SCANDINAVIAN_MILES, - CONF_SERVICE_URL, DEFAULT_UPDATE_INTERVAL, DOMAIN, PLATFORMS, - RESOURCES, UNIT_SYSTEM_IMPERIAL, UNIT_SYSTEM_METRIC, UNIT_SYSTEM_SCANDINAVIAN_MILES, @@ -49,68 +40,6 @@ from .errors import InvalidAuth _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, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL - ): vol.All(cv.time_period, vol.Clamp(min=DEFAULT_UPDATE_INTERVAL)), - vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys( - cv.string - ), - vol.Optional(CONF_RESOURCES): vol.All( - cv.ensure_list, [vol.In(RESOURCES)] - ), - vol.Optional(CONF_REGION): cv.string, - vol.Optional(CONF_SERVICE_URL): cv.string, - vol.Optional(CONF_MUTABLE, default=True): cv.boolean, - vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Migrate from YAML to ConfigEntry.""" - if DOMAIN not in config: - return True - - hass.data[DOMAIN] = {} - - if not hass.config_entries.async_entries(DOMAIN): - new_conf = {} - new_conf[CONF_USERNAME] = config[DOMAIN][CONF_USERNAME] - new_conf[CONF_PASSWORD] = config[DOMAIN][CONF_PASSWORD] - new_conf[CONF_REGION] = config[DOMAIN].get(CONF_REGION) - new_conf[CONF_SCANDINAVIAN_MILES] = config[DOMAIN][CONF_SCANDINAVIAN_MILES] - new_conf[CONF_MUTABLE] = config[DOMAIN][CONF_MUTABLE] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=new_conf - ) - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version=None, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Volvo On Call component from a ConfigEntry.""" diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py index c1b3ab3f66bd..d56d10ded5a8 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -106,10 +106,6 @@ class VolvoOnCallConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=user_schema, errors=errors ) - async def async_step_import(self, import_data) -> FlowResult: - """Import volvooncall config from configuration.yaml.""" - return await self.async_step_user(import_data) - 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( diff --git a/homeassistant/components/volvooncall/strings.json b/homeassistant/components/volvooncall/strings.json index 9e8471b04b1d..44b821b4b017 100644 --- a/homeassistant/components/volvooncall/strings.json +++ b/homeassistant/components/volvooncall/strings.json @@ -19,11 +19,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Volvo On Call YAML configuration is being removed", - "description": "Configuring the Volvo On Call platform using YAML is being removed in a future release of Home Assistant.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/volvooncall/test_config_flow.py b/tests/components/volvooncall/test_config_flow.py index 549dc9d44092..c8ed92d8ee5f 100644 --- a/tests/components/volvooncall/test_config_flow.py +++ b/tests/components/volvooncall/test_config_flow.py @@ -130,45 +130,6 @@ async def test_form_other_exception(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_import(hass: HomeAssistant) -> None: - """Test a YAML import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == FlowResultType.FORM - assert len(result["errors"]) == 0 - - with patch("volvooncall.Connection.get"), patch( - "homeassistant.components.volvooncall.async_setup", - return_value=True, - ), patch( - "homeassistant.components.volvooncall.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_reauth(hass: HomeAssistant) -> None: """Test that we handle the reauth flow.""" From a0b6da33ab4c2c11d1832342828ae041eaf5e91b Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 26 Mar 2023 09:57:13 +0200 Subject: [PATCH 0772/1058] Strict typing of UniFi integration (#90278) * Fix typing of UniFi controller * Strict typing of unifi.__init__ * Strict typing of UniFi config_flow * Strict typing of UniFi switch * Strict typing UniFi sensor * Strict typing UniFi device tracker * Strict typing of UniFi * Fix library issues related to typing --- .strict-typing | 2 +- homeassistant/components/unifi/__init__.py | 2 +- homeassistant/components/unifi/controller.py | 90 ++++++++++--------- .../components/unifi/device_tracker.py | 4 +- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/switch.py | 5 +- mypy.ini | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 57 insertions(+), 54 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9db95008927e..533d5239cab2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -311,7 +311,7 @@ homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* -homeassistant.components.unifi.update +homeassistant.components.unifi.* homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index d6405d117168..a7e8aede3619 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - controller = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) + controller: UniFiController = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) if not hass.data[UNIFI_DOMAIN]: async_unload_services(hass) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index a5f3c4d77204..60507d5a8c6b 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -10,7 +10,7 @@ from typing import Any from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.websocket import WebsocketSignal, WebsocketState +from aiounifi.websocket import WebsocketState import async_timeout from homeassistant.config_entries import ConfigEntry @@ -22,7 +22,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import ( aiohttp_client, device_registry as dr, @@ -75,31 +75,32 @@ CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) class UniFiController: """Manages a single UniFi Network instance.""" - def __init__(self, hass, config_entry, api): + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: aiounifi.Controller + ) -> None: """Initialize the system.""" self.hass = hass self.config_entry = config_entry self.api = api - api.callback = self.async_unifi_signalling_callback + api.ws_state_callback = self.async_unifi_ws_state_callback self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] self.site_id: str = "" - self._site_name = None - self._site_role = None + self._site_name: str | None = None + self._site_role: str | None = None - self._cancel_heartbeat_check = None - self._heartbeat_dispatch = {} - self._heartbeat_time = {} + self._cancel_heartbeat_check: CALLBACK_TYPE | None = None + self._heartbeat_time: dict[str, datetime] = {} self.load_config_entry_options() - self.entities = {} + self.entities: dict[str, str] = {} self.known_objects: set[tuple[str, str]] = set() - def load_config_entry_options(self): + def load_config_entry_options(self) -> None: """Store attributes to avoid property call overhead since they are called frequently.""" options = self.config_entry.options @@ -114,7 +115,7 @@ class UniFiController: CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS ) # Config entry option to not track devices. - self.option_track_devices = options.get( + self.option_track_devices: bool = options.get( CONF_TRACK_DEVICES, DEFAULT_TRACK_DEVICES ) # Config entry option listing what SSIDs are being used to track clients. @@ -133,43 +134,45 @@ class UniFiController: # Config entry option with list of clients to control network access. self.option_block_clients = options.get(CONF_BLOCK_CLIENT, []) # Config entry option to control DPI restriction groups. - self.option_dpi_restrictions = options.get( + self.option_dpi_restrictions: bool = options.get( CONF_DPI_RESTRICTIONS, DEFAULT_DPI_RESTRICTIONS ) # Statistics sensor options # Config entry option to allow bandwidth sensors. - self.option_allow_bandwidth_sensors = options.get( + self.option_allow_bandwidth_sensors: bool = options.get( CONF_ALLOW_BANDWIDTH_SENSORS, DEFAULT_ALLOW_BANDWIDTH_SENSORS ) # Config entry option to allow uptime sensors. - self.option_allow_uptime_sensors = options.get( + self.option_allow_uptime_sensors: bool = options.get( CONF_ALLOW_UPTIME_SENSORS, DEFAULT_ALLOW_UPTIME_SENSORS ) @property - def host(self): + def host(self) -> str: """Return the host of this controller.""" - return self.config_entry.data[CONF_HOST] + host: str = self.config_entry.data[CONF_HOST] + return host @property - def site(self): + def site(self) -> str: """Return the site of this config entry.""" - return self.config_entry.data[CONF_SITE_ID] + site_id: str = self.config_entry.data[CONF_SITE_ID] + return site_id @property - def site_name(self): + def site_name(self) -> str | None: """Return the nice name of site.""" return self._site_name @property - def site_role(self): + def site_role(self) -> str | None: """Return the site user role of this controller.""" return self._site_role @property - def mac(self): + def mac(self) -> str | None: """Return the mac address of this controller.""" for client in self.api.clients.values(): if self.host == client.ip: @@ -227,22 +230,21 @@ class UniFiController: async_load_entities(description) @callback - def async_unifi_signalling_callback(self, signal, data): + def async_unifi_ws_state_callback(self, state: WebsocketState) -> None: """Handle messages back from UniFi library.""" - if signal == WebsocketSignal.CONNECTION_STATE: - if data == WebsocketState.DISCONNECTED and self.available: - LOGGER.warning("Lost connection to UniFi Network") + if state == WebsocketState.DISCONNECTED and self.available: + LOGGER.warning("Lost connection to UniFi Network") - if (data == WebsocketState.RUNNING and not self.available) or ( - data == WebsocketState.DISCONNECTED and self.available - ): - self.available = data == WebsocketState.RUNNING - async_dispatcher_send(self.hass, self.signal_reachable) + if (state == WebsocketState.RUNNING and not self.available) or ( + state == WebsocketState.DISCONNECTED and self.available + ): + self.available = state == WebsocketState.RUNNING + async_dispatcher_send(self.hass, self.signal_reachable) - if not self.available: - self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) - else: - LOGGER.info("Connected to UniFi Network") + if not self.available: + self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) + else: + LOGGER.info("Connected to UniFi Network") @property def signal_reachable(self) -> str: @@ -259,7 +261,7 @@ class UniFiController: """Event specific per UniFi device tracker to signal new heartbeat missed.""" return "unifi-heartbeat-missed" - async def initialize(self): + async def initialize(self) -> None: """Set up a UniFi Network instance.""" await self.api.initialize() @@ -291,7 +293,7 @@ class UniFiController: continue client = self.api.clients_all[mac] - self.api.clients.process_raw([client.raw]) + self.api.clients.process_raw([dict(client.raw)]) LOGGER.debug( "Restore disconnected client %s (%s)", entry.entity_id, @@ -319,7 +321,7 @@ class UniFiController: del self._heartbeat_time[unique_id] @callback - def _async_check_for_stale(self, *_) -> None: + def _async_check_for_stale(self, *_: datetime) -> None: """Check for any devices scheduled to be marked disconnected.""" now = dt_util.utcnow() @@ -365,7 +367,7 @@ class UniFiController: async_dispatcher_send(hass, controller.signal_options_update) @callback - def reconnect(self, log=False) -> None: + def reconnect(self, log: bool = False) -> None: """Prepare to reconnect UniFi session.""" if log: LOGGER.info("Will try to reconnect to UniFi Network") @@ -387,14 +389,14 @@ class UniFiController: self.hass.loop.call_later(RETRY_TIMER, self.reconnect) @callback - def shutdown(self, event) -> None: + def shutdown(self, event: Event) -> None: """Wrap the call to unifi.close. Used as an argument to EventBus.async_listen_once. """ self.api.stop_websocket() - async def async_reset(self): + async def async_reset(self) -> bool: """Reset this controller to default state. Will cancel any scheduled setup retry and will unload @@ -421,15 +423,15 @@ async def get_unifi_controller( config: MappingProxyType[str, Any], ) -> aiounifi.Controller: """Create a controller object and verify authentication.""" - ssl_context = False + ssl_context: ssl.SSLContext | bool = False - if verify_ssl := bool(config.get(CONF_VERIFY_SSL)): + if verify_ssl := config.get(CONF_VERIFY_SSL): session = aiohttp_client.async_get_clientsession(hass) if isinstance(verify_ssl, str): ssl_context = ssl.create_default_context(cafile=verify_ssl) else: session = aiohttp_client.async_create_clientsession( - hass, verify_ssl=verify_ssl, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) ) controller = aiounifi.Controller( diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index f31176afe382..149f865e776e 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -19,7 +19,7 @@ from aiounifi.models.event import Event, EventKey from homeassistant.components.device_tracker import ScannerEntity, SourceType from homeassistant.config_entries import ConfigEntry -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 @@ -268,7 +268,7 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): return self._attr_unique_id @callback - def _make_disconnected(self, *_) -> None: + def _make_disconnected(self, *_: core_Event) -> None: """No heart beat by device.""" self._is_connected = False self.async_write_ha_state() diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7fde8a2ad7e6..473c4ed21a51 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==45"], + "requirements": ["aiounifi==46"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index bd0166516dc8..87c9b9f4f4f7 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -247,8 +247,9 @@ async def async_setup_entry( for mac in controller.option_block_clients: if mac not in controller.api.clients and mac in controller.api.clients_all: - client = controller.api.clients_all[mac] - controller.api.clients.process_raw([client.raw]) + controller.api.clients.process_raw( + [dict(controller.api.clients_all[mac].raw)] + ) controller.register_platform_add_entities( UnifiSwitchEntity, ENTITY_DESCRIPTIONS, async_add_entities diff --git a/mypy.ini b/mypy.ini index 760c7f6811df..b3a4cafba361 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2873,7 +2873,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.unifi.update] +[mypy-homeassistant.components.unifi.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true diff --git a/requirements_all.txt b/requirements_all.txt index 66edf5348056..daea2e693934 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -291,7 +291,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==45 +aiounifi==46 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf5998ed50fb..cde4c23f9044 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -272,7 +272,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==45 +aiounifi==46 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From f8431278c8556e71c984859ad6cbe35cd2b87a4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Mar 2023 23:05:21 -1000 Subject: [PATCH 0773/1058] Bump yalexs-ble to 2.1.6 (#90295) --- 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 3db09bdc34a4..d30d3a39fbc5 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.2.7", "yalexs-ble==2.1.5"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.6"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index ba314ecf2a45..da9e0271e40f 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.1.5"] + "requirements": ["yalexs-ble==2.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index daea2e693934..57e442108eb9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.5 +yalexs-ble==2.1.6 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cde4c23f9044..4366ec257d19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1905,7 +1905,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.5 +yalexs-ble==2.1.6 # homeassistant.components.august yalexs==1.2.7 From e0ec3488d3f4ea485e16124ebe1d4325bc9e716a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 14:20:05 +0200 Subject: [PATCH 0774/1058] Adjust IntFlag handling in syrupy (#90223) --- .core_files.yaml | 1 + .../components/elgato/snapshots/test_light.ambr | 6 +++--- tests/syrupy.py | 17 ++++++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.core_files.yaml b/.core_files.yaml index 7bf7a09b36bf..7933556b6038 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -125,6 +125,7 @@ tests: &tests - tests/mock/** - tests/pylint/** - tests/scripts/** + - tests/syrupy.py - tests/test_util/** - tests/testing_config/** - tests/util/** diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 539585167393..31f5dfba2174 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -23,7 +23,7 @@ 'supported_color_modes': list([ , ]), - 'supported_features': 0, + 'supported_features': , 'xy_color': tuple( 0.465, 0.376, @@ -130,7 +130,7 @@ , , ]), - 'supported_features': 0, + 'supported_features': , 'xy_color': tuple( 0.465, 0.376, @@ -236,7 +236,7 @@ , , ]), - 'supported_features': 0, + 'supported_features': , 'xy_color': tuple( 0.34, 0.327, diff --git a/tests/syrupy.py b/tests/syrupy.py index 4f646a05eb65..f18c11bf5d56 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -111,10 +111,10 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): serializable_data = cls._serializable_config_entry(data) elif dataclasses.is_dataclass(data): serializable_data = dataclasses.asdict(data) - elif isinstance(data, IntFlag) and data == 0: + elif isinstance(data, IntFlag): # The repr of an enum.IntFlag has changed between Python 3.10 and 3.11 - # This only concerns the 0 case, which we normalize here - serializable_data = 0 + # so we normalize it here. + serializable_data = _IntFlagWrapper(data) else: serializable_data = data with suppress(TypeError): @@ -201,6 +201,17 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): ) +class _IntFlagWrapper: + def __init__(self, flag: IntFlag) -> None: + self._flag = flag + + def __repr__(self) -> str: + # 3.10: + # 3.11: + # Syrupy: + return f"<{self._flag.__class__.__name__}: {self._flag.value}>" + + class HomeAssistantSnapshotExtension(AmberSnapshotExtension): """Home Assistant extension for Syrupy.""" From 69a46d400253fad3d4457d5febd2c4e7042d16b6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 15:21:19 +0200 Subject: [PATCH 0775/1058] Adjust pylint plugin for components fixtures (#90217) * Adjust pylint plugin for components fixtures * Adjust components * Use MagicMock * Adjust * Use None --- pylint/plugins/hass_enforce_type_hints.py | 1 + tests/components/airzone/test_sensor.py | 4 +--- tests/components/conftest.py | 12 ++++++------ tests/components/goalzero/test_sensor.py | 3 +-- tests/components/gree/test_switch.py | 11 ++++++----- tests/components/homekit_controller/test_sensor.py | 4 ++-- tests/components/kostal_plenticore/test_number.py | 11 +++++------ tests/components/litterrobot/test_binary_sensor.py | 4 ++-- tests/components/oralb/test_sensor.py | 7 +++++-- tests/components/powerwall/test_sensor.py | 4 +++- tests/components/qnap_qsw/test_binary_sensor.py | 4 +--- tests/components/qnap_qsw/test_sensor.py | 4 +--- tests/components/radarr/test_sensor.py | 3 +-- tests/components/sensibo/test_binary_sensor.py | 4 ++-- tests/components/sensibo/test_climate.py | 12 ++++++------ tests/components/sensibo/test_number.py | 6 +++--- tests/components/sensibo/test_sensor.py | 4 ++-- tests/components/sonarr/test_sensor.py | 4 ++-- tests/components/switchbot/test_sensor.py | 5 ++++- tests/components/unifi/test_sensor.py | 4 ++-- tests/components/unifiprotect/test_sensor.py | 7 +++---- 21 files changed, 59 insertions(+), 59 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f25b8db84afa..ba0a511c5719 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -104,6 +104,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "enable_statistics": "bool", "enable_schema_validation": "bool", "entity_registry": "EntityRegistry", + "entity_registry_enabled_by_default": "None", "freezer": "FrozenDateTimeFactory", "hass_access_token": "str", "hass_admin_credential": "Credentials", diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 151ee7c42fb0..1e7d335a46fa 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,14 +1,12 @@ """The sensor tests for the Airzone platform.""" -from unittest.mock import AsyncMock - from homeassistant.core import HomeAssistant from .util import async_init_integration async def test_airzone_create_sensors( - hass: HomeAssistant, entity_registry_enabled_by_default: AsyncMock + hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: """Test creation of sensors.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 6cad53aea72d..d57ef9768a00 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1,12 +1,12 @@ """Fixtures for component testing.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @pytest.fixture(scope="session", autouse=True) -def patch_zeroconf_multiple_catcher(): +def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: """Patch zeroconf wrapper that detects if multiple instances are used.""" with patch( "homeassistant.components.zeroconf.install_multiple_zeroconf_catcher", @@ -16,7 +16,7 @@ def patch_zeroconf_multiple_catcher(): @pytest.fixture(autouse=True) -def prevent_io(): +def prevent_io() -> Generator[None, None, None]: """Fixture to prevent certain I/O from happening.""" with patch( "homeassistant.components.http.ban.load_yaml_config_file", @@ -25,10 +25,10 @@ def prevent_io(): @pytest.fixture -def entity_registry_enabled_by_default() -> Generator[AsyncMock, None, None]: +def entity_registry_enabled_by_default() -> Generator[None, None, None]: """Test fixture that ensures all entities are enabled in the registry.""" with patch( "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", return_value=True, - ) as mock_entity_registry_enabled_by_default: - yield mock_entity_registry_enabled_by_default + ): + yield diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 5c979b7d84d3..47fbb29915b2 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -1,5 +1,4 @@ """Sensor tests for the Goalzero integration.""" -from unittest.mock import AsyncMock from homeassistant.components.goalzero.const import DEFAULT_NAME from homeassistant.components.sensor import ( @@ -29,7 +28,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test we get sensor data.""" await async_init_integration(hass, aioclient_mock) diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 85b9a41caff2..58c740b8591e 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -1,4 +1,5 @@ """Tests for gree component.""" + from greeclimate.exceptions import DeviceTimeoutError import pytest @@ -54,7 +55,7 @@ async def test_health_mode_disabled_by_default(hass): ], ) async def test_send_switch_on( - hass: HomeAssistant, entity, entity_registry_enabled_by_default + hass: HomeAssistant, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -82,7 +83,7 @@ async def test_send_switch_on( ], ) async def test_send_switch_on_device_timeout( - hass: HomeAssistant, device, entity, entity_registry_enabled_by_default + hass: HomeAssistant, device, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -112,7 +113,7 @@ async def test_send_switch_on_device_timeout( ], ) async def test_send_switch_off( - hass: HomeAssistant, entity, entity_registry_enabled_by_default + hass: HomeAssistant, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -140,7 +141,7 @@ async def test_send_switch_off( ], ) async def test_send_switch_toggle( - hass: HomeAssistant, entity, entity_registry_enabled_by_default + hass: HomeAssistant, entity, entity_registry_enabled_by_default: None ) -> None: """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -193,7 +194,7 @@ async def test_send_switch_toggle( ], ) async def test_entity_name( - hass: HomeAssistant, entity, name, entity_registry_enabled_by_default + hass: HomeAssistant, entity, name, entity_registry_enabled_by_default: None ) -> None: """Test for name property.""" await async_setup_gree(hass) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index c801ab91f74f..6c9ad008703c 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -364,7 +364,7 @@ def test_thread_status_to_str() -> None: async def test_rssi_sensor( hass: HomeAssistant, utcnow, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: """Test an rssi sensor.""" @@ -389,7 +389,7 @@ async def test_rssi_sensor( async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, utcnow, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: """Test an rssi sensor unique id migration.""" diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index a1eb778a1c03..beabd8fe669c 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -1,5 +1,4 @@ """Test Kostal Plenticore number.""" - from collections.abc import Generator from datetime import timedelta from unittest.mock import patch @@ -90,7 +89,7 @@ async def test_setup_all_entries( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if all available entries are setup.""" @@ -109,7 +108,7 @@ async def test_setup_no_entries( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test that no entries are setup if Plenticore does not provide data.""" @@ -130,7 +129,7 @@ async def test_number_has_value( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if number has a value if data is provided on update.""" @@ -155,7 +154,7 @@ async def test_number_is_unavailable( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if number is unavailable if no data is provided on update.""" @@ -176,7 +175,7 @@ async def test_set_value( mock_config_entry: MockConfigEntry, mock_plenticore_client: ApiClient, mock_get_setting_values: list, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Test if a new value could be set.""" diff --git a/tests/components/litterrobot/test_binary_sensor.py b/tests/components/litterrobot/test_binary_sensor.py index cbcdd4477600..c6cfbff907ed 100644 --- a/tests/components/litterrobot/test_binary_sensor.py +++ b/tests/components/litterrobot/test_binary_sensor.py @@ -1,5 +1,5 @@ """Test the Litter-Robot binary sensor entity.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest @@ -17,7 +17,7 @@ from .conftest import setup_integration async def test_binary_sensors( hass: HomeAssistant, mock_account: MagicMock, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Tests binary sensors.""" await setup_integration(hass, mock_account, PLATFORM_DOMAIN) diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index d07723014004..8c7bacce234b 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -1,4 +1,5 @@ """Test the OralB sensors.""" + from homeassistant.components.oralb.const import DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -16,7 +17,9 @@ from tests.components.bluetooth import ( ) -async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( domain=DOMAIN, @@ -47,7 +50,7 @@ async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) async def test_sensors_io_series_4( - hass: HomeAssistant, entity_registry_enabled_by_default + hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: """Test setting up creates the sensors with an io series 4.""" entry = MockConfigEntry( diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index c72a3ff6fed5..a0d4d7f9e969 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -20,7 +20,9 @@ from .mocks import _mock_powerwall_with_fixtures from tests.common import MockConfigEntry -async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test creation of the sensors.""" mock_powerwall = await _mock_powerwall_with_fixtures(hass) diff --git a/tests/components/qnap_qsw/test_binary_sensor.py b/tests/components/qnap_qsw/test_binary_sensor.py index f007f799349d..47eb6a00ba7e 100644 --- a/tests/components/qnap_qsw/test_binary_sensor.py +++ b/tests/components/qnap_qsw/test_binary_sensor.py @@ -1,7 +1,5 @@ """The binary sensor tests for the QNAP QSW platform.""" -from unittest.mock import AsyncMock - from homeassistant.components.qnap_qsw.const import ATTR_MESSAGE from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -12,7 +10,7 @@ from .util import async_init_integration async def test_qnap_qsw_create_binary_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, ) -> None: """Test creation of binary sensors.""" diff --git a/tests/components/qnap_qsw/test_sensor.py b/tests/components/qnap_qsw/test_sensor.py index 902f65d92581..673a607acdf2 100644 --- a/tests/components/qnap_qsw/test_sensor.py +++ b/tests/components/qnap_qsw/test_sensor.py @@ -1,7 +1,5 @@ """The sensor tests for the QNAP QSW platform.""" -from unittest.mock import AsyncMock - from homeassistant.components.qnap_qsw.const import ATTR_MAX from homeassistant.core import HomeAssistant @@ -10,7 +8,7 @@ from .util import async_init_integration async def test_qnap_qsw_create_sensors( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test creation of sensors.""" diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 5c4ae3ea3486..d3dde74dcbfb 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -1,5 +1,4 @@ """The tests for Radarr sensor platform.""" -from unittest.mock import AsyncMock from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT @@ -13,7 +12,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test for successfully setting up the Radarr platform.""" await setup_integration(hass, aioclient_mock) diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index 78d643eb7496..d99dd2e8715e 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed async def test_binary_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 268abc5f89b1..be5b539fa069 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -720,7 +720,7 @@ async def test_climate_no_fan_no_swing( async def test_climate_set_timer( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -824,7 +824,7 @@ async def test_climate_set_timer( async def test_climate_pure_boost( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -928,7 +928,7 @@ async def test_climate_pure_boost( async def test_climate_climate_react( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -1091,7 +1091,7 @@ async def test_climate_climate_react( async def test_climate_climate_react_fahrenheit( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -1234,7 +1234,7 @@ async def test_climate_climate_react_fahrenheit( async def test_climate_full_ac_state( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sensibo/test_number.py b/tests/components/sensibo/test_number.py index c7a6a18616b3..1f9683559601 100644 --- a/tests/components/sensibo/test_number.py +++ b/tests/components/sensibo/test_number.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -23,7 +23,7 @@ from tests.common import async_fire_time_changed async def test_number( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, get_data: SensiboData, @@ -53,7 +53,7 @@ async def test_number( async def test_number_set_value( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, get_data: SensiboData, ) -> None: diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index f84c7cf90053..8d3f705215c2 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from pysensibo.model import SensiboData import pytest @@ -16,7 +16,7 @@ from tests.common import async_fire_time_changed async def test_sensor( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, load_int: ConfigEntry, monkeypatch: pytest.pytest.MonkeyPatch, get_data: SensiboData, diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index c00236c54e60..9f27e5936578 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -1,6 +1,6 @@ """Tests for the Sonarr sensor platform.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from aiopyarr import ArrException import pytest @@ -25,7 +25,7 @@ async def test_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_sonarr: MagicMock, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ) -> None: """Test the creation and values of the sensors.""" registry = er.async_get(hass) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index e801faf257c0..80c85cdb6016 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -1,4 +1,5 @@ """Test the switchbot sensors.""" + from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.switchbot.const import DOMAIN from homeassistant.const import ( @@ -18,7 +19,9 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors(hass: HomeAssistant, entity_registry_enabled_by_default) -> None: +async def test_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test setting up creates the sensors.""" await async_setup_component(hass, DOMAIN, {}) inject_bluetooth_service_info(hass, WOHAND_SERVICE_INFO) diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index b4b82166269f..bf7ba4d53c08 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -210,7 +210,7 @@ async def test_uptime_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, initial_uptime, event_uptime, new_uptime, @@ -296,7 +296,7 @@ async def test_remove_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket, - entity_registry_enabled_by_default, + entity_registry_enabled_by_default: None, ) -> None: """Verify removing of clients work as expected.""" wired_client = { diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 0d763e6f906d..db7cdc801bf9 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -1,9 +1,8 @@ """Test the UniFi Protect sensor platform.""" - from __future__ import annotations from datetime import datetime, timedelta -from unittest.mock import AsyncMock, Mock +from unittest.mock import Mock from pyunifiprotect.data import ( NVR, @@ -398,7 +397,7 @@ async def test_sensor_setup_camera( async def test_sensor_setup_camera_with_last_trip_time( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime, @@ -474,7 +473,7 @@ async def test_sensor_update_alarm( async def test_sensor_update_alarm_with_last_trip_time( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime, From bec7bbeb9221e9f7c8c0e551ba6dacd6d41e1d97 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sun, 26 Mar 2023 16:57:19 +0200 Subject: [PATCH 0776/1058] Use SnapshotAssertion in bmw_connected_drive tests (#90128) --- .../diagnostics/diagnostics_config_entry.json | 803 ------ .../diagnostics/diagnostics_device.json | 801 ------ .../snapshots/test_diagnostics.ambr | 2373 +++++++++++++++++ .../bmw_connected_drive/test_diagnostics.py | 38 +- 4 files changed, 2389 insertions(+), 1626 deletions(-) delete mode 100644 tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json delete mode 100644 tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json create mode 100644 tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json deleted file mode 100644 index 12e85bb85234..000000000000 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_config_entry.json +++ /dev/null @@ -1,803 +0,0 @@ -{ - "info": { - "username": "**REDACTED**", - "password": "**REDACTED**", - "region": "rest_of_world", - "refresh_token": "**REDACTED**" - }, - "data": [ - { - "data": { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**", - "charging_settings": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - }, - "is_metric": true, - "fetched_at": "2022-07-10T11:00:00+00:00", - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - }, - "fuel_and_battery": { - "remaining_range_fuel": [105, "km"], - "remaining_range_electric": [174, "km"], - "remaining_range_total": [279, "km"], - "remaining_fuel": [6, "L"], - "remaining_fuel_percent": 65, - "remaining_battery_percent": 82, - "charging_status": "WAITING_FOR_CHARGING", - "charging_start_time_no_tz": "2022-07-10T18:01:00", - "charging_end_time": null, - "is_charger_connected": true, - "charging_target": 100, - "account_timezone": { - "_std_offset": "0:00:00", - "_dst_offset": "0:00:00", - "_dst_saved": "0:00:00", - "_hasdst": false, - "_tznames": ["UTC", "UTC"] - }, - "charging_start_time": "2022-07-10T18:01:00+00:00" - }, - "vehicle_location": { - "location": null, - "heading": null, - "vehicle_update_timestamp": "2022-07-10T09:25:53+00:00", - "account_region": "row", - "remote_service_position": null - }, - "doors_and_windows": { - "door_lock_state": "UNLOCKED", - "lids": [ - { "name": "hood", "state": "CLOSED", "is_closed": true }, - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "leftRear", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightRear", "state": "CLOSED", "is_closed": true }, - { "name": "trunk", "state": "CLOSED", "is_closed": true }, - { "name": "sunRoof", "state": "CLOSED", "is_closed": true } - ], - "windows": [ - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true } - ], - "all_lids_closed": true, - "all_windows_closed": true, - "open_lids": [], - "open_windows": [] - }, - "condition_based_services": { - "messages": [ - { - "service_type": "BRAKE_FLUID", - "state": "OK", - "due_date": "2022-10-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_CHECK", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_TUV", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - } - ], - "is_service_required": false - }, - "check_control_messages": { - "messages": [], - "has_check_control_messages": false - }, - "charging_profile": { - "is_pre_entry_climatization_enabled": false, - "timer_type": "WEEKLY_PLANNER", - "departure_times": [ - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "07:35:00", - "timer_id": 1, - "weekdays": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "18:00:00", - "timer_id": 2, - "weekdays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": "07:00:00", - "timer_id": 3, - "weekdays": [] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 4, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": null, - "timer_id": 4, - "weekdays": [] - } - ], - "preferred_charging_window": { - "_window_dict": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - }, - "end_time": "01:30:00", - "start_time": "18:01:00" - }, - "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING", - "ac_current_limit": null, - "ac_available_limits": null, - "charging_preferences_service_pack": "TCB1" - }, - "available_attributes": [ - "gps_position", - "vin", - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent", - "condition_based_services", - "check_control_messages", - "door_lock_state", - "timestamp", - "lids", - "windows" - ], - "brand": "bmw", - "drive_train": "ELECTRIC_WITH_RANGE_EXTENDER", - "drive_train_attributes": [ - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent" - ], - "has_combustion_drivetrain": true, - "has_electric_drivetrain": true, - "is_charging_plan_supported": true, - "is_lsc_enabled": true, - "is_remote_charge_start_enabled": false, - "is_remote_charge_stop_enabled": false, - "is_remote_climate_start_enabled": true, - "is_remote_climate_stop_enabled": false, - "is_remote_horn_enabled": true, - "is_remote_lights_enabled": true, - "is_remote_lock_enabled": true, - "is_remote_sendpoi_enabled": true, - "is_remote_set_ac_limit_enabled": false, - "is_remote_set_target_soc_enabled": false, - "is_remote_unlock_enabled": true, - "is_vehicle_active": false, - "is_vehicle_tracking_enabled": false, - "lsc_type": "ACTIVATED", - "mileage": [137009, "km"], - "name": "i3 (+ REX)", - "timestamp": "2022-07-10T09:25:53+00:00", - "vin": "**REDACTED**" - } - ], - "fingerprint": [ - { - "filename": "bmw-eadrax-vcs_v4_vehicles.json", - "content": [ - { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**" - } - ] - }, - { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, - { - "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", - "content": { - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - } - }, - { - "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", - "content": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - } - } - ] -} diff --git a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json b/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json deleted file mode 100644 index 8e1fe5019c72..000000000000 --- a/tests/components/bmw_connected_drive/fixtures/diagnostics/diagnostics_device.json +++ /dev/null @@ -1,801 +0,0 @@ -{ - "info": { - "username": "**REDACTED**", - "password": "**REDACTED**", - "region": "rest_of_world", - "refresh_token": "**REDACTED**" - }, - "data": { - "data": { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**", - "charging_settings": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - }, - "is_metric": true, - "fetched_at": "2022-07-10T11:00:00+00:00", - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - }, - "fuel_and_battery": { - "remaining_range_fuel": [105, "km"], - "remaining_range_electric": [174, "km"], - "remaining_range_total": [279, "km"], - "remaining_fuel": [6, "L"], - "remaining_fuel_percent": 65, - "remaining_battery_percent": 82, - "charging_status": "WAITING_FOR_CHARGING", - "charging_start_time_no_tz": "2022-07-10T18:01:00", - "charging_end_time": null, - "is_charger_connected": true, - "charging_target": 100, - "account_timezone": { - "_std_offset": "0:00:00", - "_dst_offset": "0:00:00", - "_dst_saved": "0:00:00", - "_hasdst": false, - "_tznames": ["UTC", "UTC"] - }, - "charging_start_time": "2022-07-10T18:01:00+00:00" - }, - "vehicle_location": { - "location": null, - "heading": null, - "vehicle_update_timestamp": "2022-07-10T09:25:53+00:00", - "account_region": "row", - "remote_service_position": null - }, - "doors_and_windows": { - "door_lock_state": "UNLOCKED", - "lids": [ - { "name": "hood", "state": "CLOSED", "is_closed": true }, - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "leftRear", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightRear", "state": "CLOSED", "is_closed": true }, - { "name": "trunk", "state": "CLOSED", "is_closed": true }, - { "name": "sunRoof", "state": "CLOSED", "is_closed": true } - ], - "windows": [ - { "name": "leftFront", "state": "CLOSED", "is_closed": true }, - { "name": "rightFront", "state": "CLOSED", "is_closed": true } - ], - "all_lids_closed": true, - "all_windows_closed": true, - "open_lids": [], - "open_windows": [] - }, - "condition_based_services": { - "messages": [ - { - "service_type": "BRAKE_FLUID", - "state": "OK", - "due_date": "2022-10-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_CHECK", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - }, - { - "service_type": "VEHICLE_TUV", - "state": "OK", - "due_date": "2023-05-01T00:00:00+00:00", - "due_distance": [null, null] - } - ], - "is_service_required": false - }, - "check_control_messages": { - "messages": [], - "has_check_control_messages": false - }, - "charging_profile": { - "is_pre_entry_climatization_enabled": false, - "timer_type": "WEEKLY_PLANNER", - "departure_times": [ - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "07:35:00", - "timer_id": 1, - "weekdays": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - "action": "DEACTIVATE", - "start_time": "18:00:00", - "timer_id": 2, - "weekdays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": "07:00:00", - "timer_id": 3, - "weekdays": [] - }, - { - "_timer_dict": { - "action": "DEACTIVATE", - "id": 4, - "timerWeekDays": [] - }, - "action": "DEACTIVATE", - "start_time": null, - "timer_id": 4, - "weekdays": [] - } - ], - "preferred_charging_window": { - "_window_dict": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - }, - "end_time": "01:30:00", - "start_time": "18:01:00" - }, - "charging_preferences": "CHARGING_WINDOW", - "charging_mode": "DELAYED_CHARGING", - "ac_current_limit": null, - "ac_available_limits": null, - "charging_preferences_service_pack": "TCB1" - }, - "available_attributes": [ - "gps_position", - "vin", - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent", - "condition_based_services", - "check_control_messages", - "door_lock_state", - "timestamp", - "lids", - "windows" - ], - "brand": "bmw", - "drive_train": "ELECTRIC_WITH_RANGE_EXTENDER", - "drive_train_attributes": [ - "remaining_range_total", - "mileage", - "charging_time_remaining", - "charging_start_time", - "charging_end_time", - "charging_time_label", - "charging_status", - "connection_status", - "remaining_battery_percent", - "remaining_range_electric", - "last_charging_end_result", - "ac_current_limit", - "charging_target", - "charging_mode", - "charging_preferences", - "is_pre_entry_climatization_enabled", - "remaining_fuel", - "remaining_range_fuel", - "remaining_fuel_percent" - ], - "has_combustion_drivetrain": true, - "has_electric_drivetrain": true, - "is_charging_plan_supported": true, - "is_lsc_enabled": true, - "is_remote_charge_start_enabled": false, - "is_remote_charge_stop_enabled": false, - "is_remote_climate_start_enabled": true, - "is_remote_climate_stop_enabled": false, - "is_remote_horn_enabled": true, - "is_remote_lights_enabled": true, - "is_remote_lock_enabled": true, - "is_remote_sendpoi_enabled": true, - "is_remote_set_ac_limit_enabled": false, - "is_remote_set_target_soc_enabled": false, - "is_remote_unlock_enabled": true, - "is_vehicle_active": false, - "is_vehicle_tracking_enabled": false, - "lsc_type": "ACTIVATED", - "mileage": [137009, "km"], - "name": "i3 (+ REX)", - "timestamp": "2022-07-10T09:25:53+00:00", - "vin": "**REDACTED**" - }, - "fingerprint": [ - { - "filename": "bmw-eadrax-vcs_v4_vehicles.json", - "content": [ - { - "appVehicleType": "CONNECTED", - "attributes": { - "a4aType": "USB_ONLY", - "bodyType": "I01", - "brand": "BMW_I", - "color": 4284110934, - "countryOfOrigin": "CZ", - "driveTrain": "ELECTRIC_WITH_RANGE_EXTENDER", - "driverGuideInfo": { - "androidAppScheme": "com.bmwgroup.driversguide.row", - "androidStoreUrl": "https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row", - "iosAppScheme": "bmwdriversguide:///open", - "iosStoreUrl": "https://apps.apple.com/de/app/id714042749?mt=8" - }, - "headUnitType": "NBT", - "hmiVersion": "ID4", - "lastFetched": "2022-07-10T09:25:53.104Z", - "model": "i3 (+ REX)", - "softwareVersionCurrent": { - "iStep": 510, - "puStep": { "month": 11, "year": 21 }, - "seriesCluster": "I001" - }, - "softwareVersionExFactory": { - "iStep": 502, - "puStep": { "month": 3, "year": 15 }, - "seriesCluster": "I001" - }, - "year": 2015 - }, - "mappingInfo": { - "isAssociated": false, - "isLmmEnabled": false, - "isPrimaryUser": true, - "mappingStatus": "CONFIRMED" - }, - "vin": "**REDACTED**" - } - ] - }, - { "filename": "mini-eadrax-vcs_v4_vehicles.json", "content": [] }, - { - "filename": "bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json", - "content": { - "capabilities": { - "climateFunction": "AIR_CONDITIONING", - "climateNow": true, - "climateTimerTrigger": "DEPARTURE_TIMER", - "horn": true, - "isBmwChargingSupported": true, - "isCarSharingSupported": false, - "isChargeNowForBusinessSupported": false, - "isChargingHistorySupported": true, - "isChargingHospitalityEnabled": false, - "isChargingLoudnessEnabled": false, - "isChargingPlanSupported": true, - "isChargingPowerLimitEnabled": false, - "isChargingSettingsEnabled": false, - "isChargingTargetSocEnabled": false, - "isClimateTimerSupported": true, - "isCustomerEsimSupported": false, - "isDCSContractManagementSupported": true, - "isDataPrivacyEnabled": false, - "isEasyChargeEnabled": false, - "isEvGoChargingSupported": false, - "isMiniChargingSupported": false, - "isNonLscFeatureEnabled": false, - "isRemoteEngineStartSupported": false, - "isRemoteHistoryDeletionSupported": false, - "isRemoteHistorySupported": true, - "isRemoteParkingSupported": false, - "isRemoteServicesActivationRequired": false, - "isRemoteServicesBookingRequired": false, - "isScanAndChargeSupported": false, - "isSustainabilitySupported": false, - "isWifiHotspotServiceSupported": false, - "lastStateCallState": "ACTIVATED", - "lights": true, - "lock": true, - "remoteChargingCommands": {}, - "sendPoi": true, - "specialThemeSupport": [], - "unlock": true, - "vehicleFinder": false, - "vehicleStateSource": "LAST_STATE_CALL" - }, - "state": { - "chargingProfile": { - "chargingControlType": "WEEKLY_PLANNER", - "chargingMode": "DELAYED_CHARGING", - "chargingPreference": "CHARGING_WINDOW", - "chargingSettings": { - "hospitality": "NO_ACTION", - "idcc": "NO_ACTION", - "targetSoc": 100 - }, - "climatisationOn": false, - "departureTimes": [ - { - "action": "DEACTIVATE", - "id": 1, - "timeStamp": { "hour": 7, "minute": 35 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 2, - "timeStamp": { "hour": 18, "minute": 0 }, - "timerWeekDays": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ] - }, - { - "action": "DEACTIVATE", - "id": 3, - "timeStamp": { "hour": 7, "minute": 0 }, - "timerWeekDays": [] - }, - { "action": "DEACTIVATE", "id": 4, "timerWeekDays": [] } - ], - "reductionOfChargeCurrent": { - "end": { "hour": 1, "minute": 30 }, - "start": { "hour": 18, "minute": 1 } - } - }, - "checkControlMessages": [], - "climateTimers": [ - { - "departureTime": { "hour": 6, "minute": 40 }, - "isWeeklyTimer": true, - "timerAction": "ACTIVATE", - "timerWeekDays": ["THURSDAY", "SUNDAY"] - }, - { - "departureTime": { "hour": 12, "minute": 50 }, - "isWeeklyTimer": false, - "timerAction": "ACTIVATE", - "timerWeekDays": ["MONDAY"] - }, - { - "departureTime": { "hour": 18, "minute": 59 }, - "isWeeklyTimer": true, - "timerAction": "DEACTIVATE", - "timerWeekDays": ["WEDNESDAY"] - } - ], - "combustionFuelLevel": { - "range": 105, - "remainingFuelLiters": 6, - "remainingFuelPercent": 65 - }, - "currentMileage": 137009, - "doorsState": { - "combinedSecurityState": "UNLOCKED", - "combinedState": "CLOSED", - "hood": "CLOSED", - "leftFront": "CLOSED", - "leftRear": "CLOSED", - "rightFront": "CLOSED", - "rightRear": "CLOSED", - "trunk": "CLOSED" - }, - "driverPreferences": { "lscPrivacyMode": "OFF" }, - "electricChargingState": { - "chargingConnectionType": "CONDUCTIVE", - "chargingLevelPercent": 82, - "chargingStatus": "WAITING_FOR_CHARGING", - "chargingTarget": 100, - "isChargerConnected": true, - "range": 174 - }, - "isLeftSteering": true, - "isLscSupported": true, - "lastFetched": "2022-06-22T14:24:23.982Z", - "lastUpdatedAt": "2022-06-22T13:58:52Z", - "range": 174, - "requiredServices": [ - { - "dateTime": "2022-10-01T00:00:00.000Z", - "description": "Next service due by the specified date.", - "status": "OK", - "type": "BRAKE_FLUID" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next vehicle check due after the specified distance or date.", - "status": "OK", - "type": "VEHICLE_CHECK" - }, - { - "dateTime": "2023-05-01T00:00:00.000Z", - "description": "Next state inspection due by the specified date.", - "status": "OK", - "type": "VEHICLE_TUV" - } - ], - "roofState": { "roofState": "CLOSED", "roofStateType": "SUN_ROOF" }, - "windowsState": { - "combinedState": "CLOSED", - "leftFront": "CLOSED", - "rightFront": "CLOSED" - } - } - } - }, - { - "filename": "bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json", - "content": { - "chargeAndClimateSettings": { - "chargeAndClimateTimer": { "showDepartureTimers": false } - }, - "chargeAndClimateTimerDetail": { - "chargingMode": { - "chargingPreference": "CHARGING_WINDOW", - "endTimeSlot": "0001-01-01T01:30:00", - "startTimeSlot": "0001-01-01T18:01:00", - "type": "TIME_SLOT" - }, - "departureTimer": { - "type": "WEEKLY_DEPARTURE_TIMER", - "weeklyTimers": [ - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY" - ], - "id": 1, - "time": "0001-01-01T07:35:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [ - "MONDAY", - "TUESDAY", - "WEDNESDAY", - "THURSDAY", - "FRIDAY", - "SATURDAY", - "SUNDAY" - ], - "id": 2, - "time": "0001-01-01T18:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 3, - "time": "0001-01-01T07:00:00", - "timerAction": "DEACTIVATE" - }, - { - "daysOfTheWeek": [], - "id": 4, - "time": "0001-01-01T00:00:00", - "timerAction": "DEACTIVATE" - } - ] - }, - "isPreconditionForDepartureActive": false - }, - "servicePack": "TCB1" - } - } - ] -} diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..349706f593de --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -0,0 +1,2373 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'data': list([ + dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'DELAYED_CHARGING', + 'charging_preferences': 'CHARGING_WINDOW', + 'charging_preferences_service_pack': 'TCB1', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:35:00', + 'timer_id': 1, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '18:00:00', + 'timer_id': 2, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': None, + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + 'end_time': '01:30:00', + 'start_time': '18:01:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'UNLOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'sunRoof', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': None, + 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time_no_tz': '2022-07-10T18:01:00', + 'charging_status': 'WAITING_FOR_CHARGING', + 'charging_target': 100, + 'is_charger_connected': True, + 'remaining_battery_percent': 82, + 'remaining_fuel': list([ + 6, + 'L', + ]), + 'remaining_fuel_percent': 65, + 'remaining_range_electric': list([ + 174, + 'km', + ]), + 'remaining_range_fuel': list([ + 105, + 'km', + ]), + 'remaining_range_total': list([ + 279, + 'km', + ]), + }), + 'has_combustion_drivetrain': True, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': False, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': False, + 'is_remote_set_target_soc_enabled': False, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': False, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 137009, + 'km', + ]), + 'name': 'i3 (+ REX)', + 'timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': None, + 'location': None, + 'remote_service_position': None, + 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + }), + 'vin': '**REDACTED**', + }), + ]), + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- +# name: test_device_diagnostics + dict({ + 'data': dict({ + 'available_attributes': list([ + 'gps_position', + 'vin', + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + 'condition_based_services', + 'check_control_messages', + 'door_lock_state', + 'timestamp', + 'lids', + 'windows', + ]), + 'brand': 'bmw', + 'charging_profile': dict({ + 'ac_available_limits': None, + 'ac_current_limit': None, + 'charging_mode': 'DELAYED_CHARGING', + 'charging_preferences': 'CHARGING_WINDOW', + 'charging_preferences_service_pack': 'TCB1', + 'departure_times': list([ + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:35:00', + 'timer_id': 1, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '18:00:00', + 'timer_id': 2, + 'weekdays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': '07:00:00', + 'timer_id': 3, + 'weekdays': list([ + ]), + }), + dict({ + '_timer_dict': dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + 'action': 'DEACTIVATE', + 'start_time': None, + 'timer_id': 4, + 'weekdays': list([ + ]), + }), + ]), + 'is_pre_entry_climatization_enabled': False, + 'preferred_charging_window': dict({ + '_window_dict': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + 'end_time': '01:30:00', + 'start_time': '18:01:00', + }), + 'timer_type': 'WEEKLY_PLANNER', + }), + 'check_control_messages': dict({ + 'has_check_control_messages': False, + 'messages': list([ + ]), + }), + 'condition_based_services': dict({ + 'is_service_required': False, + 'messages': list([ + dict({ + 'due_date': '2022-10-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'BRAKE_FLUID', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_CHECK', + 'state': 'OK', + }), + dict({ + 'due_date': '2023-05-01T00:00:00+00:00', + 'due_distance': list([ + None, + None, + ]), + 'service_type': 'VEHICLE_TUV', + 'state': 'OK', + }), + ]), + }), + 'data': dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'charging_settings': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'fetched_at': '2022-07-10T11:00:00+00:00', + 'is_metric': True, + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + 'vin': '**REDACTED**', + }), + 'doors_and_windows': dict({ + 'all_lids_closed': True, + 'all_windows_closed': True, + 'door_lock_state': 'UNLOCKED', + 'lids': list([ + dict({ + 'is_closed': True, + 'name': 'hood', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'leftRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightRear', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'trunk', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'sunRoof', + 'state': 'CLOSED', + }), + ]), + 'open_lids': list([ + ]), + 'open_windows': list([ + ]), + 'windows': list([ + dict({ + 'is_closed': True, + 'name': 'leftFront', + 'state': 'CLOSED', + }), + dict({ + 'is_closed': True, + 'name': 'rightFront', + 'state': 'CLOSED', + }), + ]), + }), + 'drive_train': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'drive_train_attributes': list([ + 'remaining_range_total', + 'mileage', + 'charging_time_remaining', + 'charging_start_time', + 'charging_end_time', + 'charging_time_label', + 'charging_status', + 'connection_status', + 'remaining_battery_percent', + 'remaining_range_electric', + 'last_charging_end_result', + 'ac_current_limit', + 'charging_target', + 'charging_mode', + 'charging_preferences', + 'is_pre_entry_climatization_enabled', + 'remaining_fuel', + 'remaining_range_fuel', + 'remaining_fuel_percent', + ]), + 'fuel_and_battery': dict({ + 'account_timezone': dict({ + '_dst_offset': '0:00:00', + '_dst_saved': '0:00:00', + '_hasdst': False, + '_std_offset': '0:00:00', + '_tznames': list([ + 'UTC', + 'UTC', + ]), + }), + 'charging_end_time': None, + 'charging_start_time': '2022-07-10T18:01:00+00:00', + 'charging_start_time_no_tz': '2022-07-10T18:01:00', + 'charging_status': 'WAITING_FOR_CHARGING', + 'charging_target': 100, + 'is_charger_connected': True, + 'remaining_battery_percent': 82, + 'remaining_fuel': list([ + 6, + 'L', + ]), + 'remaining_fuel_percent': 65, + 'remaining_range_electric': list([ + 174, + 'km', + ]), + 'remaining_range_fuel': list([ + 105, + 'km', + ]), + 'remaining_range_total': list([ + 279, + 'km', + ]), + }), + 'has_combustion_drivetrain': True, + 'has_electric_drivetrain': True, + 'is_charging_plan_supported': True, + 'is_lsc_enabled': True, + 'is_remote_charge_start_enabled': False, + 'is_remote_charge_stop_enabled': False, + 'is_remote_climate_start_enabled': True, + 'is_remote_climate_stop_enabled': False, + 'is_remote_horn_enabled': True, + 'is_remote_lights_enabled': True, + 'is_remote_lock_enabled': True, + 'is_remote_sendpoi_enabled': True, + 'is_remote_set_ac_limit_enabled': False, + 'is_remote_set_target_soc_enabled': False, + 'is_remote_unlock_enabled': True, + 'is_vehicle_active': False, + 'is_vehicle_tracking_enabled': False, + 'lsc_type': 'ACTIVATED', + 'mileage': list([ + 137009, + 'km', + ]), + 'name': 'i3 (+ REX)', + 'timestamp': '2022-07-10T09:25:53+00:00', + 'vehicle_location': dict({ + 'account_region': 'row', + 'heading': None, + 'location': None, + 'remote_service_position': None, + 'vehicle_update_timestamp': '2022-07-10T09:25:53+00:00', + }), + 'vin': '**REDACTED**', + }), + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- +# name: test_device_diagnostics_vehicle_not_found + dict({ + 'data': None, + 'fingerprint': list([ + dict({ + 'content': list([ + dict({ + 'appVehicleType': 'CONNECTED', + 'attributes': dict({ + 'a4aType': 'USB_ONLY', + 'bodyType': 'I01', + 'brand': 'BMW_I', + 'color': 4284110934, + 'countryOfOrigin': 'CZ', + 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', + 'driverGuideInfo': dict({ + 'androidAppScheme': 'com.bmwgroup.driversguide.row', + 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', + 'iosAppScheme': 'bmwdriversguide:///open', + 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', + }), + 'headUnitType': 'NBT', + 'hmiVersion': 'ID4', + 'lastFetched': '2022-07-10T09:25:53.104Z', + 'model': 'i3 (+ REX)', + 'softwareVersionCurrent': dict({ + 'iStep': 510, + 'puStep': dict({ + 'month': 11, + 'year': 21, + }), + 'seriesCluster': 'I001', + }), + 'softwareVersionExFactory': dict({ + 'iStep': 502, + 'puStep': dict({ + 'month': 3, + 'year': 15, + }), + 'seriesCluster': 'I001', + }), + 'year': 2015, + }), + 'mappingInfo': dict({ + 'isAssociated': False, + 'isLmmEnabled': False, + 'isPrimaryUser': True, + 'mappingStatus': 'CONFIRMED', + }), + 'vin': '**REDACTED**', + }), + ]), + 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': list([ + ]), + 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + }), + dict({ + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ + }), + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), + ]), + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + 'remainingFuelPercent': 65, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', + }), + }), + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT01.json', + }), + dict({ + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT01.json', + }), + ]), + 'info': dict({ + 'password': '**REDACTED**', + 'refresh_token': '**REDACTED**', + 'region': 'rest_of_world', + 'username': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index a186a52bcd86..0509409ad0a5 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -1,10 +1,10 @@ """Test BMW diagnostics.""" import datetime -import json import os import time import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.bmw_connected_drive.const import DOMAIN from homeassistant.core import HomeAssistant @@ -12,7 +12,6 @@ from homeassistant.helpers import device_registry as dr from . import setup_mocked_integration -from tests.common import load_fixture from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -22,7 +21,10 @@ from tests.typing import ClientSessionGenerator @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bmw_fixture, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" @@ -36,16 +38,15 @@ async def test_config_entry_diagnostics( hass, hass_client, mock_config_entry ) - diagnostics_fixture = json.loads( - load_fixture("diagnostics/diagnostics_config_entry.json", DOMAIN) - ) - - assert diagnostics == diagnostics_fixture + assert diagnostics == snapshot @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bmw_fixture, + snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics.""" @@ -65,16 +66,15 @@ async def test_device_diagnostics( hass, hass_client, mock_config_entry, reg_device ) - diagnostics_fixture = json.loads( - load_fixture("diagnostics/diagnostics_device.json", DOMAIN) - ) - - assert diagnostics == diagnostics_fixture + assert diagnostics == snapshot @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11)) async def test_device_diagnostics_vehicle_not_found( - hass: HomeAssistant, hass_client: ClientSessionGenerator, bmw_fixture + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bmw_fixture, + snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics when the vehicle cannot be found.""" @@ -99,10 +99,4 @@ async def test_device_diagnostics_vehicle_not_found( hass, hass_client, mock_config_entry, reg_device ) - diagnostics_fixture = json.loads( - load_fixture("diagnostics/diagnostics_device.json", DOMAIN) - ) - # Mock empty data if car is not found in account anymore - diagnostics_fixture["data"] = None - - assert diagnostics == diagnostics_fixture + assert diagnostics == snapshot From 6e92dac61ff095bcbb541a1639007c66d70b1f6b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 18:37:26 +0200 Subject: [PATCH 0777/1058] Adjust pylint plugin for return type inheritance (#90046) --- pylint/plugins/hass_enforce_type_hints.py | 13 +++---------- tests/pylint/test_enforce_type_hints.py | 6 ++++++ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index ba0a511c5719..8dbb041fa995 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -42,7 +42,6 @@ class TypeHintMatch: """named_arg_types is for named or keyword arguments""" kwargs_type: str | None = None """kwargs_type is for the special case `**kwargs`""" - check_return_type_inheritance: bool = False has_async_counterpart: bool = False def need_to_check_function(self, node: nodes.FunctionDef) -> bool: @@ -398,7 +397,6 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type=["DeviceScanner", None], - check_return_type_inheritance=True, has_async_counterpart=True, ), ], @@ -466,7 +464,6 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 2: "DiscoveryInfoType | None", }, return_type=["BaseNotificationService", None], - check_return_type_inheritance=True, has_async_counterpart=True, ), ], @@ -493,7 +490,6 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { 0: "ConfigEntry", }, return_type="OptionsFlow", - check_return_type_inheritance=True, ), TypeHintMatch( function_name="async_step_dhcp", @@ -681,7 +677,6 @@ _RESTORE_ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="extra_restore_state_data", return_type=["ExtraStoredData", None], - check_return_type_inheritance=True, ), ] _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ @@ -2842,15 +2837,13 @@ def _is_valid_return_type(match: TypeHintMatch, node: nodes.NodeNG) -> bool: match, node.right ) - if ( - match.check_return_type_inheritance - and isinstance(match.return_type, (str, list)) - and isinstance(node, nodes.Name) - ): + if isinstance(match.return_type, (str, list)) and isinstance(node, nodes.Name): if isinstance(match.return_type, str): valid_types = {match.return_type} else: valid_types = {el for el in match.return_type if isinstance(el, str)} + if "Mapping[str, Any]" in valid_types: + valid_types.add("TypedDict") try: for infer_node in node.infer(): diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 365ccc111d05..c580658b5421 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -724,6 +724,7 @@ def test_invalid_mapping_return_type( "-> Mapping[str, bool | int]", "-> dict[str, Any]", "-> dict[str, str]", + "-> CustomTypedDict", ], ) def test_valid_mapping_return_type( @@ -737,6 +738,11 @@ def test_valid_mapping_return_type( class_node = astroid.extract_node( f""" + from typing import TypedDict + + class CustomTypedDict(TypedDict): + pass + class Entity(): pass From 1baadc1d09ed9e856dc6083545da805c0b5dba15 Mon Sep 17 00:00:00 2001 From: Niels Perfors Date: Sun, 26 Mar 2023 19:32:25 +0200 Subject: [PATCH 0778/1058] Update Verisure package to 2.6.1 (#89318) Co-authored-by: Franck Nijhof Co-authored-by: RobinBolder <33325401+RobinBolder@users.noreply.github.com> Co-authored-by: Tobias Lindaaker --- CODEOWNERS | 4 +- homeassistant/components/verisure/__init__.py | 10 +- .../verisure/alarm_control_panel.py | 34 +++-- .../components/verisure/binary_sensor.py | 4 +- homeassistant/components/verisure/camera.py | 19 ++- .../components/verisure/config_flow.py | 30 ++-- homeassistant/components/verisure/const.py | 3 + .../components/verisure/coordinator.py | 140 +++++++++++++----- .../components/verisure/diagnostics.py | 1 + homeassistant/components/verisure/lock.py | 54 ++++--- .../components/verisure/manifest.json | 4 +- homeassistant/components/verisure/sensor.py | 80 ++-------- homeassistant/components/verisure/switch.py | 33 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/verisure/conftest.py | 22 ++- tests/components/verisure/test_config_flow.py | 71 ++++----- 17 files changed, 294 insertions(+), 219 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 617fc46c27c1..ff31997ce6f0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1293,8 +1293,8 @@ build.json @home-assistant/supervisor /homeassistant/components/velux/ @Julius2342 /homeassistant/components/venstar/ @garbled1 /tests/components/venstar/ @garbled1 -/homeassistant/components/verisure/ @frenck -/tests/components/verisure/ @frenck +/homeassistant/components/verisure/ @frenck @niro1987 +/tests/components/verisure/ @frenck @niro1987 /homeassistant/components/versasense/ @flamm3blemuff1n /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 9ad8db08d59b..94e8d667d752 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -6,9 +6,9 @@ import os from pathlib import Path from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_EMAIL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR @@ -34,11 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = VerisureDataUpdateCoordinator(hass, entry=entry) if not await coordinator.async_login(): - raise ConfigEntryAuthFailed - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.async_logout) - ) + raise ConfigEntryNotReady("Could not log in to verisure.") await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 5030e01c8b1c..0cfd6ebb81cf 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -55,33 +55,49 @@ class VerisureAlarm( """Return the unique ID for this entity.""" return self.coordinator.entry.data[CONF_GIID] - async def _async_set_arm_state(self, state: str, code: str | None = None) -> None: + async def _async_set_arm_state( + self, state: str, command_data: dict[str, str | dict[str, str]] + ) -> None: """Send set arm state command.""" arm_state = await self.hass.async_add_executor_job( - self.coordinator.verisure.set_arm_state, code, state + self.coordinator.verisure.request, command_data ) LOGGER.debug("Verisure set arm state %s", state) - transaction = {} - while "result" not in transaction: + result = None + while result is None: await asyncio.sleep(0.5) transaction = await self.hass.async_add_executor_job( - self.coordinator.verisure.get_arm_state_transaction, - arm_state["armStateChangeTransactionId"], + self.coordinator.verisure.request, + self.coordinator.verisure.poll_arm_state( + list(arm_state["data"].values())[0], state + ), + ) + result = ( + transaction.get("data", {}) + .get("installation", {}) + .get("armStateChangePollResult", {}) + .get("result") ) await self.coordinator.async_refresh() async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._async_set_arm_state("DISARMED", code) + await self._async_set_arm_state( + "DISARMED", self.coordinator.verisure.disarm(code) + ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._async_set_arm_state("ARMED_HOME", code) + await self._async_set_arm_state( + "ARMED_HOME", self.coordinator.verisure.arm_home(code) + ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._async_set_arm_state("ARMED_AWAY", code) + await self._async_set_arm_state( + "ARMED_AWAY", self.coordinator.verisure.arm_away(code) + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 8283480a145d..536b96ea2cba 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -109,9 +109,9 @@ class VerisureEthernetStatus( @property def is_on(self) -> bool: """Return the state of the sensor.""" - return self.coordinator.data["ethernet"] + return self.coordinator.data["broadband"]["isBroadbandConnected"] @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self.coordinator.data["ethernet"] is not None + return super().available and self.coordinator.data["broadband"] is not None diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 98ed41c5b9f0..1f890a22a644 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -63,12 +63,12 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) self.serial_number = serial_number self._directory_path = directory_path self._image: str | None = None - self._image_id = None + self._image_id: str | None = None @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["cameras"][self.serial_number]["area"] + area = self.coordinator.data["cameras"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -95,16 +95,16 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) """Check the contents of the image list.""" self.coordinator.update_smartcam_imageseries() - images = self.coordinator.imageseries.get("imageSeries", []) - new_image_id = None - for image in images: + new_image = None + for image in self.coordinator.imageseries: if image["deviceLabel"] == self.serial_number: - new_image_id = image["image"][0]["imageId"] + new_image = image break - if not new_image_id: + if not new_image: return + new_image_id = new_image["mediaId"] if new_image_id in ("-1", self._image_id): LOGGER.debug("The image is the same, or loading image_id") return @@ -113,9 +113,8 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) new_image_path = os.path.join( self._directory_path, "{}{}".format(new_image_id, ".jpg") ) - self.coordinator.verisure.download_image( - self.serial_number, new_image_id, new_image_path - ) + new_image_url = new_image["contentUrl"] + self.coordinator.verisure.download_image(new_image_url, new_image_path) LOGGER.debug("Old image_id=%s", self._image_id) self.delete_image() diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index d53c7c9ed667..9392cdd9bc12 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -56,7 +56,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.verisure = Verisure( username=self.email, password=self.password, - cookieFileName=self.hass.config.path( + cookie_file_name=self.hass.config.path( STORAGE_DIR, f"verisure_{user_input[CONF_EMAIL]}" ), ) @@ -66,7 +66,9 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): except VerisureLoginError as ex: if "Multifactor authentication enabled" in str(ex): try: - await self.hass.async_add_executor_job(self.verisure.login_mfa) + await self.hass.async_add_executor_job( + self.verisure.request_mfa + ) except ( VerisureLoginError, VerisureError, @@ -108,9 +110,8 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await self.hass.async_add_executor_job( - self.verisure.mfa_validate, user_input[CONF_CODE], True + self.verisure.validate_mfa, user_input[CONF_CODE] ) - await self.hass.async_add_executor_job(self.verisure.login) except VerisureLoginError as ex: LOGGER.debug("Could not log in to Verisure, %s", ex) errors["base"] = "invalid_auth" @@ -136,9 +137,16 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Select Verisure installation to add.""" + installations_data = await self.hass.async_add_executor_job( + self.verisure.get_installations + ) installations = { - inst["giid"]: f"{inst['alias']} ({inst['street']})" - for inst in self.verisure.installations or [] + inst["giid"]: f"{inst['alias']} ({inst['address']['street']})" + for inst in ( + installations_data.get("data", {}) + .get("account", {}) + .get("installations", []) + ) } if user_input is None: @@ -184,8 +192,8 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): self.verisure = Verisure( username=self.email, password=self.password, - cookieFileName=self.hass.config.path( - STORAGE_DIR, f"verisure-{user_input[CONF_EMAIL]}" + cookie_file_name=self.hass.config.path( + STORAGE_DIR, f"verisure_{user_input[CONF_EMAIL]}" ), ) @@ -194,7 +202,9 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): except VerisureLoginError as ex: if "Multifactor authentication enabled" in str(ex): try: - await self.hass.async_add_executor_job(self.verisure.login_mfa) + await self.hass.async_add_executor_job( + self.verisure.request_mfa + ) except ( VerisureLoginError, VerisureError, @@ -248,7 +258,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await self.hass.async_add_executor_job( - self.verisure.mfa_validate, user_input[CONF_CODE], True + self.verisure.validate_mfa, user_input[CONF_CODE] ) await self.hass.async_add_executor_job(self.verisure.login) except VerisureLoginError as ex: diff --git a/homeassistant/components/verisure/const.py b/homeassistant/components/verisure/const.py index e8720baa1d53..ac30c58fde56 100644 --- a/homeassistant/components/verisure/const.py +++ b/homeassistant/components/verisure/const.py @@ -36,6 +36,9 @@ DEVICE_TYPE_NAME = { "SMOKE3": "Smoke detector", "VOICEBOX1": "VoiceBox", "WATER1": "Water detector", + "SMOKE": "Smoke detector", + "SIREN": "Siren", + "VOICEBOX": "VoiceBox", } ALARM_STATE_TO_HA = { diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 17cadb9598f6..47fbde3ef202 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -2,19 +2,21 @@ from __future__ import annotations from datetime import timedelta -from http import HTTPStatus +from time import sleep from verisure import ( Error as VerisureError, + LoginError as VerisureLoginError, ResponseError as VerisureResponseError, Session as Verisure, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.storage import STORAGE_DIR -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import Throttle from .const import CONF_GIID, DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER @@ -25,13 +27,14 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize the Verisure hub.""" - self.imageseries: dict[str, list] = {} + self.imageseries: list[dict[str, str]] = [] self.entry = entry + self._overview: list[dict] = [] self.verisure = Verisure( username=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - cookieFileName=hass.config.path( + cookie_file_name=hass.config.path( STORAGE_DIR, f"verisure_{entry.data[CONF_EMAIL]}" ), ) @@ -43,8 +46,11 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): async def async_login(self) -> bool: """Login to Verisure.""" try: - await self.hass.async_add_executor_job(self.verisure.login) - except VerisureError as ex: + await self.hass.async_add_executor_job(self.verisure.login_cookie) + except VerisureLoginError as ex: + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex + except VerisureResponseError as ex: LOGGER.error("Could not log in to verisure, %s", ex) return False @@ -54,62 +60,116 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): return True - async def async_logout(self, _event: Event) -> None: - """Logout from Verisure.""" - try: - await self.hass.async_add_executor_job(self.verisure.logout) - except VerisureError as ex: - LOGGER.error("Could not log out from verisure, %s", ex) - async def _async_update_data(self) -> dict: """Fetch data from Verisure.""" try: - overview = await self.hass.async_add_executor_job( - self.verisure.get_overview - ) + await self.hass.async_add_executor_job(self.verisure.update_cookie) + except VerisureLoginError as ex: + LOGGER.error("Credentials expired for Verisure, %s", ex) + raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex except VerisureResponseError as ex: - LOGGER.error("Could not read overview, %s", ex) - if ex.status_code == HTTPStatus.SERVICE_UNAVAILABLE: - LOGGER.info("Trying to log in again") - await self.async_login() - return {} - raise + LOGGER.error("Could not log in to verisure, %s", ex) + raise ConfigEntryAuthFailed("Could not log in to verisure") from ex + try: + overview = await self.hass.async_add_executor_job( + self.verisure.request, + self.verisure.arm_state(), + self.verisure.broadband(), + self.verisure.cameras(), + self.verisure.climate(), + self.verisure.door_window(), + self.verisure.smart_lock(), + self.verisure.smartplugs(), + ) + except VerisureResponseError as err: + LOGGER.debug("Cookie expired or service unavailable, %s", err) + overview = self._overview + try: + await self.hass.async_add_executor_job(self.verisure.update_cookie) + except VerisureResponseError as ex: + raise ConfigEntryAuthFailed("Credentials for Verisure expired.") from ex + except VerisureError as err: + LOGGER.error("Could not read overview, %s", err) + raise UpdateFailed("Could not read overview") from err + + def unpack(overview: list, value: str) -> dict | list: + return next( + ( + item["data"]["installation"][value] + for item in overview + if value in item.get("data", {}).get("installation", {}) + ), + [], + ) # Store data in a way Home Assistant can easily consume it + self._overview = overview return { - "alarm": overview["armState"], - "ethernet": overview.get("ethernetConnectedNow"), + "alarm": unpack(overview, "armState"), + "broadband": unpack(overview, "broadband"), "cameras": { - device["deviceLabel"]: device - for device in overview["customerImageCameras"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "cameras") }, "climate": { - device["deviceLabel"]: device for device in overview["climateValues"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "climates") }, "door_window": { - device["deviceLabel"]: device - for device in overview["doorWindow"]["doorWindowDevice"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "doorWindows") }, "locks": { - device["deviceLabel"]: device - for device in overview["doorLockStatusList"] - }, - "mice": { - device["deviceLabel"]: device - for device in overview["eventCounts"] - if device["deviceType"] == "MOUSE1" + device["device"]["deviceLabel"]: device + for device in unpack(overview, "smartLocks") }, "smart_plugs": { - device["deviceLabel"]: device for device in overview["smartPlugs"] + device["device"]["deviceLabel"]: device + for device in unpack(overview, "smartplugs") }, } @Throttle(timedelta(seconds=60)) def update_smartcam_imageseries(self) -> None: """Update the image series.""" - self.imageseries = self.verisure.get_camera_imageseries() + image_data = self.verisure.request(self.verisure.cameras_image_series()) + self.imageseries = [ + content + for series in ( + image_data.get("data", {}) + .get("ContentProviderMediaSearch", {}) + .get("mediaSeriesList", []) + ) + for content in series.get("deviceMediaList", []) + if content.get("contentType") == "IMAGE_JPEG" + ] @Throttle(timedelta(seconds=30)) def smartcam_capture(self, device_id: str) -> None: """Capture a new image from a smartcam.""" - self.verisure.capture_image(device_id) + capture_request = self.verisure.request( + self.verisure.camera_get_request_id(device_id) + ) + request_id = ( + capture_request.get("data", {}) + .get("ContentProviderCaptureImageRequest", {}) + .get("requestId") + ) + capture_status = None + attempts = 0 + while capture_status != "AVAILABLE": + if attempts == 30: + break + if attempts > 1: + sleep(0.5) + attempts += 1 + capture_data = self.verisure.request( + self.verisure.camera_capture(device_id, request_id) + ) + capture_status = ( + capture_data.get("data", {}) + .get("installation", {}) + .get("cameraContentProvider", {}) + .get("captureImageRequestStatus", {}) + .get("mediaRequestStatus") + ) diff --git a/homeassistant/components/verisure/diagnostics.py b/homeassistant/components/verisure/diagnostics.py index 740aff0b908a..8dbffe6eee33 100644 --- a/homeassistant/components/verisure/diagnostics.py +++ b/homeassistant/components/verisure/diagnostics.py @@ -16,6 +16,7 @@ TO_REDACT = { "deviceArea", "name", "time", + "reportTime", "userString", } diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 02cdad158ca0..d13005b265db 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -77,7 +77,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["locks"][self.serial_number]["area"] + area = self.coordinator.data["locks"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -98,12 +98,16 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt @property def changed_by(self) -> str | None: """Last change triggered by.""" - return self.coordinator.data["locks"][self.serial_number].get("userString") + return ( + self.coordinator.data["locks"][self.serial_number] + .get("user", {}) + .get("name") + ) @property def changed_method(self) -> str: """Last change method.""" - return self.coordinator.data["locks"][self.serial_number]["method"] + return self.coordinator.data["locks"][self.serial_number]["lockMethod"] @property def code_format(self) -> str: @@ -114,8 +118,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt def is_locked(self) -> bool: """Return true if lock is locked.""" return ( - self.coordinator.data["locks"][self.serial_number]["lockedState"] - == "LOCKED" + self.coordinator.data["locks"][self.serial_number]["lockStatus"] == "LOCKED" ) @property @@ -147,28 +150,39 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt async def async_set_lock_state(self, code: str, state: str) -> None: """Send set lock state command.""" - target_state = "lock" if state == STATE_LOCKED else "unlock" - lock_state = await self.hass.async_add_executor_job( - self.coordinator.verisure.set_lock_state, - code, - self.serial_number, - target_state, + command = ( + self.coordinator.verisure.door_lock(self.serial_number, code) + if state == STATE_LOCKED + else self.coordinator.verisure.door_unlock(self.serial_number, code) + ) + lock_request = await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + command, ) - LOGGER.debug("Verisure doorlock %s", state) - transaction = {} + transaction_id = lock_request.get("data", {}).get(command["operationName"]) + target_state = "LOCKED" if state == STATE_LOCKED else "UNLOCKED" + lock_status = None attempts = 0 - while "result" not in transaction: - transaction = await self.hass.async_add_executor_job( - self.coordinator.verisure.get_lock_state_transaction, - lock_state["doorLockStateChangeTransactionId"], - ) - attempts += 1 + while lock_status != "OK": if attempts == 30: break if attempts > 1: await asyncio.sleep(0.5) - if transaction["result"] == "OK": + attempts += 1 + poll_data = await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + self.coordinator.verisure.poll_lock_state( + transaction_id, self.serial_number, target_state + ), + ) + lock_status = ( + poll_data.get("data", {}) + .get("installation", {}) + .get("doorLockStateChangePollResult", {}) + .get("result") + ) + if lock_status == "OK": self._state = state def disable_autolock(self) -> None: diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 9e177a514a13..66dccdc07de9 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -1,7 +1,7 @@ { "domain": "verisure", "name": "Verisure", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@niro1987"], "config_flow": true, "dhcp": [ { @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==1.8.1"] + "requirements": ["vsure==2.6.1"] } diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index bbc1c15159c3..0b519b472694 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -28,18 +28,13 @@ async def async_setup_entry( sensors: list[Entity] = [ VerisureThermometer(coordinator, serial_number) for serial_number, values in coordinator.data["climate"].items() - if "temperature" in values + if "temperatureValue" in values ] sensors.extend( VerisureHygrometer(coordinator, serial_number) for serial_number, values in coordinator.data["climate"].items() - if "humidity" in values - ) - - sensors.extend( - VerisureMouseDetection(coordinator, serial_number) - for serial_number in coordinator.data["mice"] + if values.get("humidityEnabled") ) async_add_entities(sensors) @@ -67,10 +62,10 @@ class VerisureThermometer( @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number].get( - "deviceType" - ) - area = self.coordinator.data["climate"][self.serial_number]["deviceArea"] + device_type = self.coordinator.data["climate"][self.serial_number]["device"][ + "gui" + ]["label"] + area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -84,7 +79,7 @@ class VerisureThermometer( @property def native_value(self) -> str | None: """Return the state of the entity.""" - return self.coordinator.data["climate"][self.serial_number]["temperature"] + return self.coordinator.data["climate"][self.serial_number]["temperatureValue"] @property def available(self) -> bool: @@ -92,7 +87,8 @@ class VerisureThermometer( return ( super().available and self.serial_number in self.coordinator.data["climate"] - and "temperature" in self.coordinator.data["climate"][self.serial_number] + and "temperatureValue" + in self.coordinator.data["climate"][self.serial_number] ) @@ -118,10 +114,10 @@ class VerisureHygrometer( @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - device_type = self.coordinator.data["climate"][self.serial_number].get( - "deviceType" - ) - area = self.coordinator.data["climate"][self.serial_number]["deviceArea"] + device_type = self.coordinator.data["climate"][self.serial_number]["device"][ + "gui" + ]["label"] + area = self.coordinator.data["climate"][self.serial_number]["device"]["area"] return DeviceInfo( name=area, suggested_area=area, @@ -135,7 +131,7 @@ class VerisureHygrometer( @property def native_value(self) -> str | None: """Return the state of the entity.""" - return self.coordinator.data["climate"][self.serial_number]["humidity"] + return self.coordinator.data["climate"][self.serial_number]["humidityValue"] @property def available(self) -> bool: @@ -143,51 +139,5 @@ class VerisureHygrometer( return ( super().available and self.serial_number in self.coordinator.data["climate"] - and "humidity" in self.coordinator.data["climate"][self.serial_number] - ) - - -class VerisureMouseDetection( - CoordinatorEntity[VerisureDataUpdateCoordinator], SensorEntity -): - """Representation of a Verisure mouse detector.""" - - _attr_name = "Mouse" - _attr_has_entity_name = True - _attr_native_unit_of_measurement = "Mice" - - def __init__( - self, coordinator: VerisureDataUpdateCoordinator, serial_number: str - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self._attr_unique_id = f"{serial_number}_mice" - self.serial_number = serial_number - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this entity.""" - area = self.coordinator.data["mice"][self.serial_number]["area"] - return DeviceInfo( - name=area, - suggested_area=area, - manufacturer="Verisure", - model="Mouse detector", - identifiers={(DOMAIN, self.serial_number)}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_GIID]), - configuration_url="https://mypages.verisure.com", - ) - - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self.coordinator.data["mice"][self.serial_number]["detections"] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and self.serial_number in self.coordinator.data["mice"] - and "detections" in self.coordinator.data["mice"][self.serial_number] + and "humidityValue" in self.coordinator.data["climate"][self.serial_number] ) diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index ffb6e434fea9..62e9bdf6cf80 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -47,7 +47,9 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch @property def device_info(self) -> DeviceInfo: """Return device information about this entity.""" - area = self.coordinator.data["smart_plugs"][self.serial_number]["area"] + area = self.coordinator.data["smart_plugs"][self.serial_number]["device"][ + "area" + ] return DeviceInfo( name=area, suggested_area=area, @@ -77,16 +79,23 @@ class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], Switch and self.serial_number in self.coordinator.data["smart_plugs"] ) - def turn_on(self, **kwargs: Any) -> None: - """Set smartplug status on.""" - self.coordinator.verisure.set_smartplug_state(self.serial_number, True) - self._state = True - self._change_timestamp = monotonic() - self.schedule_update_ha_state() + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the smartplug on.""" + await self.async_set_plug_state(True) - def turn_off(self, **kwargs: Any) -> None: - """Set smartplug status off.""" - self.coordinator.verisure.set_smartplug_state(self.serial_number, False) - self._state = False + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the smartplug off.""" + await self.async_set_plug_state(False) + + async def async_set_plug_state(self, state: bool) -> None: + """Set smartplug state.""" + command: dict[ + str, str | dict[str, str] + ] = self.coordinator.verisure.set_smartplug(self.serial_number, state) + await self.hass.async_add_executor_job( + self.coordinator.verisure.request, + command, + ) + self._state = state self._change_timestamp = monotonic() - self.schedule_update_ha_state() + await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 57e442108eb9..997a206f58bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2589,7 +2589,7 @@ volkszaehler==0.4.0 volvooncall==0.10.2 # homeassistant.components.verisure -vsure==1.8.1 +vsure==2.6.1 # homeassistant.components.vasttrafik vtjp==0.1.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4366ec257d19..e3c60c53ba3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1847,7 +1847,7 @@ vilfo-api-client==0.3.2 volvooncall==0.10.2 # homeassistant.components.verisure -vsure==1.8.1 +vsure==2.6.1 # homeassistant.components.vulcan vulcan-api==2.3.0 diff --git a/tests/components/verisure/conftest.py b/tests/components/verisure/conftest.py index f91215866d89..8ddc3a998155 100644 --- a/tests/components/verisure/conftest.py +++ b/tests/components/verisure/conftest.py @@ -43,8 +43,22 @@ def mock_verisure_config_flow() -> Generator[None, MagicMock, None]: ) as verisure_mock: verisure = verisure_mock.return_value verisure.login.return_value = True - verisure.installations = [ - {"giid": "12345", "alias": "ascending", "street": "12345th street"}, - {"giid": "54321", "alias": "descending", "street": "54321th street"}, - ] + verisure.get_installations.return_value = { + "data": { + "account": { + "installations": [ + { + "giid": "12345", + "alias": "ascending", + "address": {"street": "12345th street"}, + }, + { + "giid": "54321", + "alias": "descending", + "address": {"street": "54321th street"}, + }, + ] + } + } + } yield verisure diff --git a/tests/components/verisure/test_config_flow.py b/tests/components/verisure/test_config_flow.py index e330a341d8e8..b1e67766df8b 100644 --- a/tests/components/verisure/test_config_flow.py +++ b/tests/components/verisure/test_config_flow.py @@ -35,9 +35,10 @@ async def test_full_user_flow_single_installation( assert result.get("type") == FlowResultType.FORM assert result.get("errors") == {} - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -133,9 +134,10 @@ async def test_full_user_flow_single_installation_with_mfa( assert result2.get("step_id") == "mfa" mock_verisure_config_flow.login.side_effect = None - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -153,9 +155,9 @@ async def test_full_user_flow_single_installation_with_mfa( CONF_PASSWORD: "SuperS3cr3t!", } - assert len(mock_verisure_config_flow.login.mock_calls) == 2 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1 + assert len(mock_verisure_config_flow.login.mock_calls) == 1 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 1 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -215,9 +217,9 @@ async def test_full_user_flow_multiple_installations_with_mfa( CONF_PASSWORD: "SuperS3cr3t!", } - assert len(mock_verisure_config_flow.login.mock_calls) == 2 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1 + assert len(mock_verisure_config_flow.login.mock_calls) == 1 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 1 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -257,7 +259,7 @@ async def test_verisure_errors( mock_verisure_config_flow.login.side_effect = VerisureLoginError( "Multifactor authentication enabled, disable or create MFA cookie" ) - mock_verisure_config_flow.login_mfa.side_effect = side_effect + mock_verisure_config_flow.request_mfa.side_effect = side_effect result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -268,7 +270,7 @@ async def test_verisure_errors( ) await hass.async_block_till_done() - mock_verisure_config_flow.login_mfa.side_effect = None + mock_verisure_config_flow.request_mfa.side_effect = None assert result3.get("type") == FlowResultType.FORM assert result3.get("step_id") == "user" @@ -286,7 +288,7 @@ async def test_verisure_errors( assert result4.get("type") == FlowResultType.FORM assert result4.get("step_id") == "mfa" - mock_verisure_config_flow.mfa_validate.side_effect = side_effect + mock_verisure_config_flow.validate_mfa.side_effect = side_effect result5 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -298,11 +300,11 @@ async def test_verisure_errors( assert result5.get("step_id") == "mfa" assert result5.get("errors") == {"base": error} - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] - - mock_verisure_config_flow.mfa_validate.side_effect = None + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } + mock_verisure_config_flow.validate_mfa.side_effect = None mock_verisure_config_flow.login.side_effect = None result6 = await hass.config_entries.flow.async_configure( @@ -321,9 +323,9 @@ async def test_verisure_errors( CONF_PASSWORD: "SuperS3cr3t!", } - assert len(mock_verisure_config_flow.login.mock_calls) == 4 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 2 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 2 + assert len(mock_verisure_config_flow.login.mock_calls) == 3 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 2 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 2 assert len(mock_setup_entry.mock_calls) == 1 @@ -441,8 +443,8 @@ async def test_reauth_flow_with_mfa( } assert len(mock_verisure_config_flow.login.mock_calls) == 2 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 1 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 1 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 1 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -491,7 +493,7 @@ async def test_reauth_flow_errors( mock_verisure_config_flow.login.side_effect = VerisureLoginError( "Multifactor authentication enabled, disable or create MFA cookie" ) - mock_verisure_config_flow.login_mfa.side_effect = side_effect + mock_verisure_config_flow.request_mfa.side_effect = side_effect result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -506,7 +508,7 @@ async def test_reauth_flow_errors( assert result3.get("step_id") == "reauth_confirm" assert result3.get("errors") == {"base": "unknown_mfa"} - mock_verisure_config_flow.login_mfa.side_effect = None + mock_verisure_config_flow.request_mfa.side_effect = None result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], @@ -520,7 +522,7 @@ async def test_reauth_flow_errors( assert result4.get("type") == FlowResultType.FORM assert result4.get("step_id") == "reauth_mfa" - mock_verisure_config_flow.mfa_validate.side_effect = side_effect + mock_verisure_config_flow.validate_mfa.side_effect = side_effect result5 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -532,11 +534,12 @@ async def test_reauth_flow_errors( assert result5.get("step_id") == "reauth_mfa" assert result5.get("errors") == {"base": error} - mock_verisure_config_flow.mfa_validate.side_effect = None + mock_verisure_config_flow.validate_mfa.side_effect = None mock_verisure_config_flow.login.side_effect = None - mock_verisure_config_flow.installations = [ - mock_verisure_config_flow.installations[0] - ] + mock_verisure_config_flow.get_installations.return_value = { + k1: {k2: {k3: [v3[0]] for k3, v3 in v2.items()} for k2, v2 in v1.items()} + for k1, v1 in mock_verisure_config_flow.get_installations.return_value.items() + } await hass.config_entries.flow.async_configure( result5["flow_id"], @@ -553,8 +556,8 @@ async def test_reauth_flow_errors( } assert len(mock_verisure_config_flow.login.mock_calls) == 4 - assert len(mock_verisure_config_flow.login_mfa.mock_calls) == 2 - assert len(mock_verisure_config_flow.mfa_validate.mock_calls) == 2 + assert len(mock_verisure_config_flow.request_mfa.mock_calls) == 2 + assert len(mock_verisure_config_flow.validate_mfa.mock_calls) == 2 assert len(mock_setup_entry.mock_calls) == 1 From a036e31495b4ce4da4532a6eb90563e600c536cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:51:48 +0200 Subject: [PATCH 0779/1058] Use SnapshotAssertion in gree switch tests (#90222) --- .../gree/snapshots/test_switch.ambr | 206 ++++++++++++++++++ tests/components/gree/test_switch.py | 50 ++--- 2 files changed, 231 insertions(+), 25 deletions(-) create mode 100644 tests/components/gree/snapshots/test_switch.ambr diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr new file mode 100644 index 000000000000..73056fcc4650 --- /dev/null +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -0,0 +1,206 @@ +# serializer version: 1 +# name: test_entity_state + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Panel Light', + 'icon': 'mdi:lightbulb', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_panel_light', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Quiet', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_quiet', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Fresh Air', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_fresh_air', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 XFan', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_xfan', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'fake-device-1 Health mode', + 'icon': 'mdi:pine-tree', + }), + 'context': , + 'entity_id': 'switch.fake_device_1_health_mode', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + ]) +# --- +# name: test_registry_settings + list([ + 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.fake_device_1_panel_light', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:lightbulb', + 'original_name': 'fake-device-1 Panel Light', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Panel Light', + 'unit_of_measurement': None, + }), + 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.fake_device_1_quiet', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake-device-1 Quiet', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Quiet', + 'unit_of_measurement': None, + }), + 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.fake_device_1_fresh_air', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake-device-1 Fresh Air', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Fresh Air', + 'unit_of_measurement': None, + }), + 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.fake_device_1_xfan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'fake-device-1 XFan', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_XFan', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.fake_device_1_health_mode', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:pine-tree', + 'original_name': 'fake-device-1 Health mode', + 'platform': 'gree', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aabbcc112233_Health mode', + 'unit_of_measurement': None, + }), + ]) +# --- diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 58c740b8591e..aee9c985e8c4 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -1,13 +1,14 @@ """Tests for gree component.""" +from unittest.mock import patch from greeclimate.exceptions import DeviceTimeoutError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -27,21 +28,26 @@ ENTITY_ID_FRESH_AIR = f"{DOMAIN}.fake_device_1_fresh_air" ENTITY_ID_XFAN = f"{DOMAIN}.fake_device_1_xfan" -async def async_setup_gree(hass): +async def async_setup_gree(hass: HomeAssistant) -> MockConfigEntry: """Set up the gree switch platform.""" - MockConfigEntry(domain=GREE_DOMAIN).add_to_hass(hass) + entry = MockConfigEntry(domain=GREE_DOMAIN) + entry.add_to_hass(hass) await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {DOMAIN: {}}}) await hass.async_block_till_done() + return entry -async def test_health_mode_disabled_by_default(hass): - """Test for making sure health mode is disabled on first load.""" - await async_setup_gree(hass) +@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +async def test_registry_settings( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test for entity registry settings (disabled_by, unique_id).""" + entry = await async_setup_gree(hass) - assert ( - er.async_get(hass).async_get(ENTITY_ID_HEALTH_MODE).disabled_by - == er.RegistryEntryDisabler.INTEGRATION - ) + state = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert state == snapshot @pytest.mark.parametrize( @@ -183,20 +189,14 @@ async def test_send_switch_toggle( assert state.state == STATE_ON -@pytest.mark.parametrize( - ("entity", "name"), - [ - (ENTITY_ID_LIGHT_PANEL, "Panel Light"), - (ENTITY_ID_HEALTH_MODE, "Health mode"), - (ENTITY_ID_QUIET, "Quiet"), - (ENTITY_ID_FRESH_AIR, "Fresh Air"), - (ENTITY_ID_XFAN, "XFan"), - ], -) -async def test_entity_name( - hass: HomeAssistant, entity, name, entity_registry_enabled_by_default: None +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entity_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test for name property.""" + """Test for entity registry settings (disabled_by, unique_id).""" await async_setup_gree(hass) - state = hass.states.get(entity) - assert state.attributes[ATTR_FRIENDLY_NAME] == f"fake-device-1 {name}" + + state = hass.states.async_all(DOMAIN) + assert state == snapshot From 3058cc8d56eff58e56ceec3b066592bbd0877f0c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:52:55 +0200 Subject: [PATCH 0780/1058] Adjust targets type hint in notify platform (#90062) --- homeassistant/components/notify/legacy.py | 4 ++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 2d91e1c065a0..110671864e3a 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast @@ -221,7 +221,7 @@ class BaseNotificationService: registered_targets: dict[str, Any] @property - def targets(self) -> dict[str, Any] | None: + def targets(self) -> Mapping[str, Any] | None: """Return a dictionary of registered targets.""" return None diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 8dbb041fa995..7d11237fe5d5 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2035,7 +2035,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { matches=[ TypeHintMatch( function_name="targets", - return_type=["dict[str, Any]", None], + return_type=["Mapping[str, Any]", None], ), TypeHintMatch( function_name="send_message", From 3b83340f6e0f7a1119e5dd04fa879fe67cc027ed Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:54:01 +0200 Subject: [PATCH 0781/1058] Improve get_browse_image type hints in media player (#90057) --- homeassistant/components/braviatv/media_player.py | 2 +- homeassistant/components/forked_daapd/media_player.py | 2 +- homeassistant/components/kodi/media_player.py | 2 +- homeassistant/components/media_player/__init__.py | 2 +- homeassistant/components/philips_js/media_player.py | 2 +- homeassistant/components/roku/media_player.py | 2 +- homeassistant/components/sonos/media_player.py | 2 +- homeassistant/components/squeezebox/media_player.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index c09df32aea3a..ff5691f9aed2 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -231,7 +231,7 @@ class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index d5f40c37b516..e1f1ece055bd 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -873,7 +873,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 63875236bef0..3272491a06d3 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -928,7 +928,7 @@ class KodiEntity(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 8810ea165d6c..0f827d607369 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1138,7 +1138,7 @@ class MediaPlayerImageView(HomeAssistantView): self, request: web.Request, entity_id: str, - media_content_type: str | None = None, + media_content_type: MediaType | str | None = None, media_content_id: str | None = None, ) -> web.Response: """Start a get request.""" diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index e8250dc8eba5..c6ca70bdc847 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -415,7 +415,7 @@ class PhilipsTVMediaPlayer( async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index cf6563519ff5..877e58233d5b 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -265,7 +265,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 1ef86429cb4e..cb18ec43887a 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -691,7 +691,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 5c6f45c6aeba..d3fae39bc4d3 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -634,7 +634,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): async def async_get_browse_image( self, - media_content_type: str, + media_content_type: MediaType | str, media_content_id: str, media_image_id: str | None = None, ) -> tuple[bytes | None, str | None]: diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 7d11237fe5d5..9f4c806dc943 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1776,7 +1776,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { TypeHintMatch( function_name="async_get_browse_image", arg_types={ - 1: "str", + 1: "MediaType | str", 2: "str", 3: "str | None", }, From c075dac9163dd36aca48417a0ecee9b2bb3d4c96 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 19:54:21 +0200 Subject: [PATCH 0782/1058] Fix pylint plugin for tuple[float, float] returns (#90047) --- pylint/plugins/hass_enforce_type_hints.py | 2 +- tests/pylint/test_enforce_type_hints.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 9f4c806dc943..63cc5a1c6b92 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2796,7 +2796,7 @@ def _is_valid_type( _is_valid_type(match.group(1), node.value) and isinstance(node.slice, nodes.Tuple) and all( - _is_valid_type(match.group(n + 2), node.slice.elts[n]) + _is_valid_type(match.group(n + 2), node.slice.elts[n], in_return) for n in range(len(node.slice.elts)) ) ) diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index c580658b5421..9e8df452b61f 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -776,7 +776,7 @@ def test_valid_long_tuple( # Set ignore option type_hint_checker.config.ignore_missing_annotations = False - class_node, _, _ = astroid.extract_node( + class_node, _, _, _ = astroid.extract_node( """ class Entity(): pass @@ -790,6 +790,12 @@ def test_valid_long_tuple( class TestLight( #@ LightEntity ): + @property + def hs_color( #@ + self + ) -> tuple[int, int]: + pass + @property def rgbw_color( #@ self From 89355e087952417a6824507fd3b197f9d0520e19 Mon Sep 17 00:00:00 2001 From: Alexey Baturin Date: Sun, 26 Mar 2023 20:03:03 +0200 Subject: [PATCH 0783/1058] Add WLED IP as a sensor (#90241) Co-authored-by: Franck Nijhof --- homeassistant/components/wled/sensor.py | 7 +++++++ tests/components/wled/fixtures/rgb.json | 3 ++- tests/components/wled/fixtures/rgb_no_update.json | 3 ++- tests/components/wled/fixtures/rgb_single_segment.json | 3 ++- tests/components/wled/fixtures/rgb_websocket.json | 3 ++- tests/components/wled/fixtures/rgbw.json | 3 ++- tests/components/wled/snapshots/test_diagnostics.ambr | 2 +- tests/components/wled/test_sensor.py | 9 +++++++++ 8 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 924414cadf3f..668b90159b54 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -128,6 +128,13 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda device: device.info.wifi.bssid if device.info.wifi else None, ), + WLEDSensorEntityDescription( + key="ip", + name="IP", + icon="mdi:ip-network", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.info.ip, + ), ) diff --git a/tests/components/wled/fixtures/rgb.json b/tests/components/wled/fixtures/rgb.json index c66c07b33952..21f9b005b722 100644 --- a/tests/components/wled/fixtures/rgb.json +++ b/tests/components/wled/fixtures/rgb.json @@ -86,7 +86,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgb_no_update.json b/tests/components/wled/fixtures/rgb_no_update.json index a3c54dd11865..c8aa902cc952 100644 --- a/tests/components/wled/fixtures/rgb_no_update.json +++ b/tests/components/wled/fixtures/rgb_no_update.json @@ -86,7 +86,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgb_single_segment.json b/tests/components/wled/fixtures/rgb_single_segment.json index 08b1cb7bb603..aa0b79e98f58 100644 --- a/tests/components/wled/fixtures/rgb_single_segment.json +++ b/tests/components/wled/fixtures/rgb_single_segment.json @@ -68,7 +68,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgb_websocket.json b/tests/components/wled/fixtures/rgb_websocket.json index 36ca3e1f792a..4a0ed7b1ee5a 100644 --- a/tests/components/wled/fixtures/rgb_websocket.json +++ b/tests/components/wled/fixtures/rgb_websocket.json @@ -94,7 +94,8 @@ "opt": 127, "brand": "WLED", "product": "FOSS", - "mac": "aabbccddeeff" + "mac": "aabbccddeeff", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/fixtures/rgbw.json b/tests/components/wled/fixtures/rgbw.json index 7ffcaa36f90a..100b3936900b 100644 --- a/tests/components/wled/fixtures/rgbw.json +++ b/tests/components/wled/fixtures/rgbw.json @@ -68,7 +68,8 @@ "brand": "WLED", "product": "DIY light", "btype": "bin", - "mac": "aabbccddee11" + "mac": "aabbccddee11", + "ip": "127.0.0.1" }, "effects": [ "Solid", diff --git a/tests/components/wled/snapshots/test_diagnostics.ambr b/tests/components/wled/snapshots/test_diagnostics.ambr index 25db6a3116b1..643e5fe4ad02 100644 --- a/tests/components/wled/snapshots/test_diagnostics.ambr +++ b/tests/components/wled/snapshots/test_diagnostics.ambr @@ -92,7 +92,7 @@ 'effect_count': 81, 'filesystem': None, 'free_heap': 14600, - 'ip': 'Unknown', + 'ip': '127.0.0.1', 'leds': dict({ '__type': "", 'repr': 'Leds(cct=False, count=30, fps=None, light_capabilities=None, max_power=850, max_segments=10, power=470, rgbw=False, wv=True, segment_light_capabilities=None)', diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index f4016ce37aa4..d9168d7b6975 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -110,6 +110,15 @@ async def test_sensors( assert entry.unique_id == "aabbccddeeff_wifi_bssid" 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_UNIT_OF_MEASUREMENT) is None + assert state.state == "127.0.0.1" + + assert (entry := entity_registry.async_get("sensor.wled_rgb_light_ip")) + assert entry.unique_id == "aabbccddeeff_ip" + assert entry.entity_category is EntityCategory.DIAGNOSTIC + @pytest.mark.parametrize( "entity_id", From 542def7f82212b4734128a6945312610bc95821f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 09:10:35 -1000 Subject: [PATCH 0784/1058] Bump pySwitchbot to 0.37.5 (#90317) --- 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 ada24bcee577..31ce20bea3f1 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -40,5 +40,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.37.4"] + "requirements": ["PySwitchbot==0.37.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 997a206f58bf..343660ab3de9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -40,7 +40,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.4 +PySwitchbot==0.37.5 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3c60c53ba3e..2cdff72fc823 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.37.4 +PySwitchbot==0.37.5 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 From 45262c61145f080673938078aa15a5b31877cfaa Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 26 Mar 2023 21:14:17 +0200 Subject: [PATCH 0785/1058] Implement config flow for nextcloud (#89396) * implement config flow * add tests * fix hassfest and requirements * abort import on connection error * add add_suggested_values_to_schema * mock async_setup_entry * revert code owner change * fix try connect in config flow * add device info * allow multiple instances * fix import in config flow * remove custom scan interval from coordinator * applay suggestions * apply suggestions * take over ownership from @meichthys * cleanup import data before passing to user step * apply suggestions to tests * add untested files to .coveragerc --- .coveragerc | 7 +- CODEOWNERS | 3 +- .../components/nextcloud/__init__.py | 73 ++++++--- .../components/nextcloud/binary_sensor.py | 21 +-- .../components/nextcloud/config_flow.py | 78 +++++++++ .../components/nextcloud/coordinator.py | 11 +- .../components/nextcloud/manifest.json | 3 +- homeassistant/components/nextcloud/sensor.py | 20 +-- .../components/nextcloud/strings.json | 28 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/nextcloud/__init__.py | 1 + tests/components/nextcloud/conftest.py | 25 +++ .../nextcloud/snapshots/test_config_flow.ambr | 15 ++ .../components/nextcloud/test_config_flow.py | 151 ++++++++++++++++++ 16 files changed, 383 insertions(+), 59 deletions(-) create mode 100644 homeassistant/components/nextcloud/config_flow.py create mode 100644 homeassistant/components/nextcloud/strings.json create mode 100644 tests/components/nextcloud/__init__.py create mode 100644 tests/components/nextcloud/conftest.py create mode 100644 tests/components/nextcloud/snapshots/test_config_flow.ambr create mode 100644 tests/components/nextcloud/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f3dbc3479195..17db4ef9cde4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -778,7 +778,12 @@ omit = homeassistant/components/nexia/climate.py homeassistant/components/nexia/entity.py homeassistant/components/nexia/switch.py - homeassistant/components/nextcloud/* + homeassistant/components/nextcloud/__init__.py + homeassistant/components/nextcloud/binary_sensor.py + homeassistant/components/nextcloud/const.py + homeassistant/components/nextcloud/coordinator.py + homeassistant/components/nextcloud/entity.py + homeassistant/components/nextcloud/sensor.py homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/nibe_heatpump/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index ff31997ce6f0..1acd5f6c9f7b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -785,7 +785,8 @@ build.json @home-assistant/supervisor /tests/components/nexia/ @bdraco /homeassistant/components/nextbus/ @vividboarder /tests/components/nextbus/ @vividboarder -/homeassistant/components/nextcloud/ @meichthys +/homeassistant/components/nextcloud/ @mib1185 +/tests/components/nextcloud/ @mib1185 /homeassistant/components/nextdns/ @bieniu /tests/components/nextdns/ @bieniu /homeassistant/components/nfandroidtv/ @tkdrob diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 5dffcbf9fbac..d2ad3edf1cb2 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -4,6 +4,7 @@ import logging from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -12,42 +13,71 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import DEFAULT_SCAN_INTERVAL, DOMAIN from .coordinator import NextcloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) - PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) # Validate user configuration CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - } - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_URL): cv.url, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + }, + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Nextcloud integration.""" - conf = config[DOMAIN] + if DOMAIN in config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + 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 the Nextcloud integration.""" + + def _connect_nc(): + return NextcloudMonitor( + entry.data[CONF_URL], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) try: - ncm = await hass.async_add_executor_job( - NextcloudMonitor, conf[CONF_URL], conf[CONF_USERNAME], conf[CONF_PASSWORD] - ) + ncm = await hass.async_add_executor_job(_connect_nc) except NextcloudMonitorError: _LOGGER.error("Nextcloud setup failed - Check configuration") return False @@ -55,13 +85,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: coordinator = NextcloudDataUpdateCoordinator( hass, ncm, - conf, + entry, ) - hass.data[DOMAIN] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 52ddb6600717..0d960bea8ef0 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -2,9 +2,9 @@ from __future__ import annotations 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.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -18,24 +18,17 @@ BINARY_SENSORS = ( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the Nextcloud sensors.""" - if discovery_info is None: - return - coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] - - add_entities( + """Set up the Nextcloud binary sensors.""" + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( [ NextcloudBinarySensor(coordinator, name) for name in coordinator.data if name in BINARY_SENSORS - ], - True, + ] ) diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py new file mode 100644 index 000000000000..e297a6893a7b --- /dev/null +++ b/homeassistant/components/nextcloud/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow to configure the Nextcloud integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +DATA_SCHEMA_USER = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) +_LOGGER = logging.getLogger(__name__) + + +class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a Nextcloud config flow.""" + + VERSION = 1 + + def _try_connect_nc(self, user_input: dict) -> NextcloudMonitor: + """Try to connect to nextcloud server.""" + return NextcloudMonitor( + user_input[CONF_URL], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle a flow initiated by configuration file.""" + self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) + try: + await self.hass.async_add_executor_job(self._try_connect_nc, user_input) + except NextcloudMonitorError: + _LOGGER.error( + "Connection error during import of yaml configuration, import aborted" + ) + return self.async_abort(reason="connection_error_during_import") + return await self.async_step_user( + { + CONF_URL: user_input[CONF_URL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + + 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({CONF_URL: user_input.get(CONF_URL)}) + try: + await self.hass.async_add_executor_job(self._try_connect_nc, user_input) + except NextcloudMonitorError: + errors["base"] = "connection_error" + else: + return self.async_create_entry( + title=user_input[CONF_URL], + data=user_input, + ) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA_USER, user_input) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/nextcloud/coordinator.py b/homeassistant/components/nextcloud/coordinator.py index 07dc76d41dd4..73a07a77e232 100644 --- a/homeassistant/components/nextcloud/coordinator.py +++ b/homeassistant/components/nextcloud/coordinator.py @@ -5,9 +5,9 @@ from typing import Any from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_URL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_SCAN_INTERVAL, DOMAIN @@ -19,18 +19,17 @@ class NextcloudDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Nextcloud data update coordinator.""" def __init__( - self, hass: HomeAssistant, ncm: NextcloudMonitor, config: ConfigType + self, hass: HomeAssistant, ncm: NextcloudMonitor, entry: ConfigEntry ) -> None: """Initialize the Nextcloud coordinator.""" - self.config = config self.ncm = ncm - self.url = config[CONF_URL] + self.url = entry.data[CONF_URL] super().__init__( hass, _LOGGER, name=self.url, - update_interval=config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + update_interval=DEFAULT_SCAN_INTERVAL, ) # Use recursion to create list of sensors & values based on nextcloud api data diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json index 366c6eeb5640..72e992277c69 100644 --- a/homeassistant/components/nextcloud/manifest.json +++ b/homeassistant/components/nextcloud/manifest.json @@ -1,7 +1,8 @@ { "domain": "nextcloud", "name": "Nextcloud", - "codeowners": ["@meichthys"], + "codeowners": ["@mib1185"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextcloud", "iot_class": "cloud_polling", "requirements": ["nextcloudmonitor==1.1.0"] diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 459f22d30eb6..eb6043e4bc6d 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity +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, StateType +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -57,24 +58,17 @@ SENSORS = ( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nextcloud sensors.""" - if discovery_info is None: - return - coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN] - - add_entities( + coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( [ NextcloudSensor(coordinator, name) for name in coordinator.data if name in SENSORS - ], - True, + ] ) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json new file mode 100644 index 000000000000..9ae7ed24a60f --- /dev/null +++ b/homeassistant/components/nextcloud/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "flow_title": "Nextcloud", + "step": { + "user": { + "description": "Enter your Nextcloud information.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "connection_error_during_import": "Connection error occured during yaml configuration import" + }, + "error": { + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Netxcloud YAML configuration has been deprecated", + "description": "Configuring Netxcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6656972f8b07..37480904f9e4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -282,6 +282,7 @@ FLOWS = { "netatmo", "netgear", "nexia", + "nextcloud", "nextdns", "nfandroidtv", "nibe_heatpump", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 843b8ed006d7..7340980f1374 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3616,7 +3616,7 @@ "nextcloud": { "name": "Nextcloud", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextdns": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cdff72fc823..6573d81e550c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -887,6 +887,9 @@ nettigo-air-monitor==2.1.0 # homeassistant.components.nexia nexia==2.0.6 +# homeassistant.components.nextcloud +nextcloudmonitor==1.1.0 + # homeassistant.components.discord nextcord==2.0.0a8 diff --git a/tests/components/nextcloud/__init__.py b/tests/components/nextcloud/__init__.py new file mode 100644 index 000000000000..e2102ed8c250 --- /dev/null +++ b/tests/components/nextcloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nextcloud integration.""" diff --git a/tests/components/nextcloud/conftest.py b/tests/components/nextcloud/conftest.py new file mode 100644 index 000000000000..0ea281abb49d --- /dev/null +++ b/tests/components/nextcloud/conftest.py @@ -0,0 +1,25 @@ +"""Fixtrues for the Nextcloud integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + + +@pytest.fixture +def mock_nextcloud_monitor() -> Mock: + """Mock of NextcloudMonitor.""" + ncm = Mock( + update=Mock(return_value=True), + ) + + return ncm + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nextcloud.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr new file mode 100644 index 000000000000..0c9df1238cf5 --- /dev/null +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -0,0 +1,15 @@ +# serializer version: 1 +# name: test_import + dict({ + 'password': 'nc_pass', + 'url': 'nc_url', + 'username': 'nc_user', + }) +# --- +# name: test_user_create_entry + dict({ + 'password': 'nc_pass', + 'url': 'nc_url', + 'username': 'nc_user', + }) +# --- diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py new file mode 100644 index 000000000000..118d8fef0dad --- /dev/null +++ b/tests/components/nextcloud/test_config_flow.py @@ -0,0 +1,151 @@ +"""Tests for the Nextcloud config flow.""" +from unittest.mock import Mock, patch + +from nextcloudmonitor import NextcloudMonitorError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nextcloud import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +VALID_CONFIG = {CONF_URL: "nc_url", CONF_USERNAME: "nc_user", CONF_PASSWORD: "nc_pass"} + + +async def test_user_create_entry( + hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion +) -> None: + """Test that the user step works.""" + 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["errors"] == {} + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_error"} + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "nc_url" + assert result["data"] == snapshot + + +async def test_user_already_configured( + hass: HomeAssistant, mock_nextcloud_monitor: Mock +) -> None: + """Test that errors are shown when duplicates are added.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="nc_url", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + 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["errors"] == {} + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import( + hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion +) -> None: + """Test that the import step works.""" + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "nc_url" + assert result["data"] == snapshot + + +async def test_import_already_configured( + hass: HomeAssistant, mock_nextcloud_monitor: Mock +) -> None: + """Test that import step is aborted when duplicates are added.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="nc_url", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_connection_error(hass: HomeAssistant) -> None: + """Test that import step is aborted on connection error.""" + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "connection_error_during_import" From 94cc188885503d0e326f8a60d974676dea2c3d74 Mon Sep 17 00:00:00 2001 From: Igor Santos <532299+igorsantos07@users.noreply.github.com> Date: Sun, 26 Mar 2023 16:37:24 -0300 Subject: [PATCH 0786/1058] [Issue template] Point to health page for version info (#80708) --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5bb755750e18..237fc2888ab3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -31,9 +31,9 @@ body: label: What version of Home Assistant Core has the issue? placeholder: core- description: > - Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/). + Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/). - [![Open your Home Assistant instance and show your Home Assistant version information.](https://my.home-assistant.io/badges/info.svg)](https://my.home-assistant.io/redirect/info/) + [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) - type: input attributes: label: What was the last working version of Home Assistant Core? @@ -46,9 +46,9 @@ body: attributes: label: What type of installation are you running? description: > - Can be found in: [Settings -> System-> Repairs -> Three Dots in Upper Right -> System information](https://my.home-assistant.io/redirect/system_health/). + Can be found in: [Settings ⇒ System ⇒ Repairs ⇒ Three Dots in Upper Right ⇒ System information](https://my.home-assistant.io/redirect/system_health/). - [![Open your Home Assistant instance and show health information about your system.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) + [![Open your Home Assistant instance and show the system information.](https://my.home-assistant.io/badges/system_health.svg)](https://my.home-assistant.io/redirect/system_health/) options: - Home Assistant OS - Home Assistant Container From 916b274ec89dc7648479cebaf1a0432e0b81a7ad Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 26 Mar 2023 21:47:47 +0200 Subject: [PATCH 0787/1058] Update pipdeptree to 2.7.0 (#90312) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index fa1688152029..caf29fc558ad 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ pre-commit==3.1.0 pydantic==1.10.7 pylint==2.17.0 pylint-per-file-ignores==1.1.0 -pipdeptree==2.5.0 +pipdeptree==2.7.0 pytest-asyncio==0.20.3 pytest-aiohttp==1.0.4 pytest-cov==3.0.0 From 8c9966aa0533c2cfd23461ecaa819e90380884b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 10:06:14 -1000 Subject: [PATCH 0788/1058] Ensure esphome subscribes to bluetooth connection free before accepting connect requests (#90319) --- .../components/esphome/bluetooth/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index 4a70b906b1fc..e62b54655c88 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -78,15 +78,18 @@ async def async_connect_scanner( scanner = ESPHomeScanner( hass, source, entry.title, new_info_callback, connector, connectable ) + if connectable: + # If its connectable be sure not to register the scanner + # until we know the connection is fully setup since otherwise + # there is a race condition where the connection can fail + await cli.subscribe_bluetooth_connections_free( + entry_data.async_update_ble_connection_limits + ) unload_callbacks = [ async_register_scanner(hass, scanner, connectable), scanner.async_setup(), ] await cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) - if connectable: - await cli.subscribe_bluetooth_connections_free( - entry_data.async_update_ble_connection_limits - ) @hass_callback def _async_unload() -> None: From 745df277a0ae0875228fa311d79c887cc9609ea2 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Sun, 26 Mar 2023 13:08:36 -0700 Subject: [PATCH 0789/1058] Fix Todoist end date for all day event (#89837) --- homeassistant/components/todoist/calendar.py | 2 +- tests/components/todoist/test_calendar.py | 56 +++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 8fdafee6cfd8..02459b429c4d 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -612,7 +612,7 @@ class TodoistProjectData: event = CalendarEvent( summary=task.content, start=due_date_value, - end=due_date_value, + end=due_date_value + timedelta(days=1), ) events.append(event) return events diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 82eff0d75535..adf0f8a14b05 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,6 +1,8 @@ """Unit tests for the Todoist calendar platform.""" -from datetime import datetime +from datetime import datetime, timedelta +from http import HTTPStatus from unittest.mock import AsyncMock, patch +import urllib import pytest from todoist_api_python.models import Due, Label, Project, Task @@ -11,6 +13,9 @@ from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.util import dt + +from tests.typing import ClientSessionGenerator @pytest.fixture(name="task") @@ -68,6 +73,11 @@ def mock_api(task) -> AsyncMock: return api +def get_events_url(entity: str, start: str, end: str) -> str: + """Create a url to get events during the specified time range.""" + return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + + @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_entity_unique_id( todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry @@ -139,3 +149,47 @@ async def test_calendar_custom_project_unique_id( state = hass.states.get("calendar.all_projects") assert state.state == "off" + + +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_all_day_event( + todoist_api, hass: HomeAssistant, hass_client: ClientSessionGenerator, api +) -> None: + """Test for an all day calendar event.""" + todoist_api.return_value = api + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], + } + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "calendar.all_projects") + client = await hass_client() + start = dt.now() - timedelta(days=1) + end = dt.now() + timedelta(days=1) + response = await client.get( + get_events_url("calendar.all_projects", start.isoformat(), end.isoformat()) + ) + assert response.status == HTTPStatus.OK + events = await response.json() + + expected = [ + { + "start": {"date": dt.now().strftime("%Y-%m-%d")}, + "end": {"date": (dt.now() + timedelta(days=1)).strftime("%Y-%m-%d")}, + "summary": "A task", + "description": None, + "location": None, + "uid": None, + "recurrence_id": None, + "rrule": None, + } + ] + assert events == expected From fa35867765cb38a39c0e07c6c34ad771a5dec112 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 26 Mar 2023 14:00:45 -0700 Subject: [PATCH 0790/1058] Bump ical to 4.5.1 and set PRODID for home assistant in local calendar ics (#90291) --- homeassistant/components/local_calendar/calendar.py | 3 +++ homeassistant/components/local_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/local_calendar/snapshots/test_diagnostics.ambr | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 2905e98caab9..718c65ffce22 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -33,6 +33,8 @@ from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) +PRODID = "-//homeassistant.io//local_calendar 1.0//EN" + async def async_setup_entry( hass: HomeAssistant, @@ -43,6 +45,7 @@ async def async_setup_entry( store = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() calendar = IcsCalendarStream.calendar_from_ics(ics) + calendar.prodid = PRODID name = config_entry.data[CONF_CALENDAR_NAME] entity = LocalCalendarEntity(store, calendar, name, unique_id=config_entry.entry_id) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index 42cd7fcf5a90..049f9de03ea3 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==4.5.0"] + "requirements": ["ical==4.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 343660ab3de9..b7f2f6a181f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -952,7 +952,7 @@ ibm-watson==5.2.2 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==4.5.0 +ical==4.5.1 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6573d81e550c..0c4581d87ec2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -723,7 +723,7 @@ iaqualink==0.5.0 ibeacon_ble==1.0.1 # homeassistant.components.local_calendar -ical==4.5.0 +ical==4.5.1 # homeassistant.components.ping icmplib==3.0 diff --git a/tests/components/local_calendar/snapshots/test_diagnostics.ambr b/tests/components/local_calendar/snapshots/test_diagnostics.ambr index e61b9da7a903..a70b9d7438bd 100644 --- a/tests/components/local_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/local_calendar/snapshots/test_diagnostics.ambr @@ -3,7 +3,7 @@ dict({ 'ics': ''' BEGIN:VCALENDAR - PRODID:-//github.com/allenporter/ical//4.5.0//EN + PRODID:-//homeassistant.io//local_calendar 1.0//EN VERSION:*** BEGIN:VEVENT DTSTAMP:20230313T190500 From 0393797ade9b25054928fd50305e43646381aa14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20=C3=98stergaard=20Nielsen?= Date: Sun, 26 Mar 2023 23:32:06 +0200 Subject: [PATCH 0791/1058] Bump ihcsdk to 2.8.5 (#90266) --- homeassistant/components/ihc/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json index 13bf8bb6d859..2400206c3a06 100644 --- a/homeassistant/components/ihc/manifest.json +++ b/homeassistant/components/ihc/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ihc", "iot_class": "local_push", "loggers": ["ihcsdk"], - "requirements": ["defusedxml==0.7.1", "ihcsdk==2.7.6"] + "requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7f2f6a181f1..d06ee4e2bf16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -964,7 +964,7 @@ ifaddr==0.1.7 iglo==1.2.7 # homeassistant.components.ihc -ihcsdk==2.7.6 +ihcsdk==2.8.5 # homeassistant.components.incomfort incomfort-client==0.5.0 From 2642d375052da2da43f5856e638fce8ada292625 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Sun, 26 Mar 2023 17:40:59 -0400 Subject: [PATCH 0792/1058] Set qbittorrent integration_type to service (#90236) --- homeassistant/components/qbittorrent/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index 2c1a7be74fa7..47090ab8b91c 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -3,6 +3,7 @@ "name": "qBittorrent", "codeowners": ["@geoffreylagaisse"], "documentation": "https://www.home-assistant.io/integrations/qbittorrent", + "integration_type": "service", "iot_class": "local_polling", "loggers": ["qbittorrent"], "requirements": ["python-qbittorrent==0.4.2"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7340980f1374..4001adbd2037 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4309,7 +4309,7 @@ }, "qbittorrent": { "name": "qBittorrent", - "integration_type": "hub", + "integration_type": "service", "config_flow": false, "iot_class": "local_polling" }, From c06ec1f78fb1fafa4d3da0737f7cc779514c8b57 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 26 Mar 2023 23:46:52 +0200 Subject: [PATCH 0793/1058] Improve onewire test coverage (#90184) --- tests/components/onewire/test_init.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 5a69fb95e165..01c1841d178b 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,4 +1,5 @@ """Tests for 1-Wire config flow.""" +from copy import deepcopy from unittest.mock import MagicMock, patch import aiohttp @@ -74,6 +75,27 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N assert not hass.data.get(DOMAIN) +async def test_update_options( + hass: HomeAssistant, config_entry: ConfigEntry, owproxy: MagicMock +) -> None: + """Test update options triggers reload.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert owproxy.call_count == 1 + + new_options = deepcopy(dict(config_entry.options)) + new_options["device_options"].clear() + hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + assert owproxy.call_count == 2 + + @patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) async def test_registry_cleanup( hass: HomeAssistant, From 16028dc9bc1beaa9fa2e50fb901eb28c8a64ae3f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 26 Mar 2023 17:52:01 -0400 Subject: [PATCH 0794/1058] Add milliseconds as valid duration sensor unit (#90018) --- homeassistant/components/sensor/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 356eb68b4dbd..892bc611b3da 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -485,6 +485,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { UnitOfTime.HOURS, UnitOfTime.MINUTES, UnitOfTime.SECONDS, + UnitOfTime.MILLISECONDS, }, SensorDeviceClass.ENERGY: set(UnitOfEnergy), SensorDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), From 00ce7570510f7cc5a215c2f5ef4cbf8d0e124b5a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 27 Mar 2023 00:10:57 +0200 Subject: [PATCH 0795/1058] Apply late review comments from #89396 in Nextcloud (#90327) --- .coveragerc | 1 - homeassistant/components/nextcloud/__init__.py | 9 +++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 17db4ef9cde4..da7cc42ba15f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -780,7 +780,6 @@ omit = homeassistant/components/nexia/switch.py homeassistant/components/nextcloud/__init__.py homeassistant/components/nextcloud/binary_sensor.py - homeassistant/components/nextcloud/const.py homeassistant/components/nextcloud/coordinator.py homeassistant/components/nextcloud/entity.py homeassistant/components/nextcloud/sensor.py diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index d2ad3edf1cb2..d2514b9091db 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -94,3 +94,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Nextcloud integration.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok From bdd095423b4dfdc6a4900a567cfdd2cc12f874f8 Mon Sep 17 00:00:00 2001 From: skrynklarn <20681457+skrynklarn@users.noreply.github.com> Date: Mon, 27 Mar 2023 00:17:12 +0200 Subject: [PATCH 0796/1058] Add last trip time attribute to Verisure binary sensors (#89944) --- homeassistant/components/verisure/binary_sensor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 536b96ea2cba..a960107c7140 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -6,11 +6,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import CONF_GIID, DOMAIN from .coordinator import VerisureDataUpdateCoordinator @@ -79,6 +80,15 @@ class VerisureDoorWindowSensor( and self.serial_number in self.coordinator.data["door_window"] ) + @property + def extra_state_attributes(self): + """Return the state attributes of the sensor.""" + return { + ATTR_LAST_TRIP_TIME: dt_util.parse_datetime( + self.coordinator.data["door_window"][self.serial_number]["reportTime"] + ) + } + class VerisureEthernetStatus( CoordinatorEntity[VerisureDataUpdateCoordinator], BinarySensorEntity From a733ca96a2108ab35b7c5db212fc6b4aec1fda27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:17:32 -1000 Subject: [PATCH 0797/1058] Bump yalexs-ble to 2.1.9 (#90320) --- 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 d30d3a39fbc5..7884ba6a4ba9 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.2.7", "yalexs-ble==2.1.6"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.9"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index da9e0271e40f..5c7adf09e370 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.1.6"] + "requirements": ["yalexs-ble==2.1.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index d06ee4e2bf16..68aece18467c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.6 +yalexs-ble==2.1.9 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c4581d87ec2..5b63fb59b6a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1908,7 +1908,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.6 +yalexs-ble==2.1.9 # homeassistant.components.august yalexs==1.2.7 From a7c796a2f7ccc7d609363824a5f2b720d72db546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:30:00 -1000 Subject: [PATCH 0798/1058] Ensure esphome connected future is awaited when connecting is canceled (#90329) --- .../components/esphome/bluetooth/client.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 71d081ff6a47..c332fc3441e1 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -322,15 +322,24 @@ class ESPHomeClient(BaseBleakClient): address_type=self._address_type, ) ) + except asyncio.CancelledError: + if connected_future.done(): + with contextlib.suppress(BleakError): + # If we are cancelled while connecting, + # we need to make sure we await the future + # to avoid a warning about an un-retrieved + # exception. + await connected_future + raise except Exception: - with contextlib.suppress(BleakError): - # If the connect call throws an exception, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception since we prefer to raise the - # exception from the connect call as it - # will be more descriptive. - if connected_future.done(): + if connected_future.done(): + with contextlib.suppress(BleakError): + # If the connect call throws an exception, + # we need to make sure we await the future + # to avoid a warning about an un-retrieved + # exception since we prefer to raise the + # exception from the connect call as it + # will be more descriptive. await connected_future connected_future.cancel() raise From ce9099a38664524cbba8801130cd0107c19aa6c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:30:17 -1000 Subject: [PATCH 0799/1058] Bump cryptography to 40.0.1 (#90326) --- homeassistant/package_constraints.txt | 10 +++++----- pyproject.toml | 6 +++--- requirements.txt | 4 ++-- script/gen_requirements_all.py | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c91ab060b5c6..cd568947cf43 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ bluetooth-auto-recovery==1.0.3 bluetooth-data-tools==0.3.1 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==39.0.1 +cryptography==40.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 hass-nabucasa==0.62.0 @@ -35,7 +35,7 @@ paho-mqtt==1.6.1 pillow==9.4.0 pip>=21.0,<23.1 psutil-home-assistant==0.0.1 -pyOpenSSL==23.0.0 +pyOpenSSL==23.1.0 pyserial==3.5 python-slugify==4.0.1 pyudev==0.23.2 @@ -144,9 +144,9 @@ pandas==1.4.3;python_version<'3.11' # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.0.0 or later required to avoid import errors when -# cryptography 39.0.0 is installed with botocore -pyOpenSSL>=23.0.0 +# pyOpenSSL 23.1.0 or later required to avoid import errors when +# cryptography 40.0.1 is installed with botocore +pyOpenSSL>=23.1.0 # uamqp newer versions we currently can't build for armv7/armhf # Limit this to Python 3.10, to not block Python 3.11 dev for now diff --git a/pyproject.toml b/pyproject.toml index 5d39a99c0325..577ba181401e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,9 @@ dependencies = [ "lru-dict==1.1.8", "PyJWT==2.6.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==39.0.1", - # pyOpenSSL 23.0.0 is required to work with cryptography 39+ - "pyOpenSSL==23.0.0", + "cryptography==40.0.1", + # pyOpenSSL 23.1.0 is required to work with cryptography 39+ + "pyOpenSSL==23.1.0", "orjson==3.8.7", "pip>=21.0,<23.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index 2386015c8444..84726cb49d9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,8 +16,8 @@ ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.6.0 -cryptography==39.0.1 -pyOpenSSL==23.0.0 +cryptography==40.0.1 +pyOpenSSL==23.1.0 orjson==3.8.7 pip>=21.0,<23.1 python-slugify==4.0.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index cd53635d966a..564d0e2eb005 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -151,9 +151,9 @@ pandas==1.4.3;python_version<'3.11' # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 -# pyOpenSSL 23.0.0 or later required to avoid import errors when -# cryptography 39.0.0 is installed with botocore -pyOpenSSL>=23.0.0 +# pyOpenSSL 23.1.0 or later required to avoid import errors when +# cryptography 40.0.1 is installed with botocore +pyOpenSSL>=23.1.0 # uamqp newer versions we currently can't build for armv7/armhf # Limit this to Python 3.10, to not block Python 3.11 dev for now From 65e46e326171e4ca24ab7411ad6f7b7df3725039 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 12:58:07 -1000 Subject: [PATCH 0800/1058] Bump aioesphomeapi to 13.6.0 (#90330) --- 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 95b6c091d5f6..ac98592da9ac 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], - "requirements": ["aioesphomeapi==13.5.1", "esphome-dashboard-api==1.2.3"], + "requirements": ["aioesphomeapi==13.6.0", "esphome-dashboard-api==1.2.3"], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 68aece18467c..7500baa41a09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -156,7 +156,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.1 +aioesphomeapi==13.6.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b63fb59b6a0..3be11d6cd6eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioecowitt==2023.01.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.5.1 +aioesphomeapi==13.6.0 # homeassistant.components.flo aioflo==2021.11.0 From 75e28826e06eb709c3102fdcb816986d8122d22f Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 27 Mar 2023 01:22:20 +0200 Subject: [PATCH 0801/1058] Upgrade netgear_lte third-party library to v0.0.15 (#90324) * Upgrade netgear_lte third-party library to 0.0.15 * Create explicit tasks for asyncio.wait() --- homeassistant/components/netgear_lte/__init__.py | 5 ++++- homeassistant/components/netgear_lte/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index fd2c399fb347..0ab3dd07edf6 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -221,7 +221,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: netgear_lte_config = config[DOMAIN] # Set up each modem - tasks = [_setup_lte(hass, lte_conf) for lte_conf in netgear_lte_config] + tasks = [ + hass.async_create_task(_setup_lte(hass, lte_conf)) + for lte_conf in netgear_lte_config + ] await asyncio.wait(tasks) # Load platforms for each modem diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index ae580dbb99b6..427aa9633c87 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], - "requirements": ["eternalegypt==0.0.12"] + "requirements": ["eternalegypt==0.0.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7500baa41a09..31e78c40c201 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -679,7 +679,7 @@ epsonprinter==0.0.9 esphome-dashboard-api==1.2.3 # homeassistant.components.netgear_lte -eternalegypt==0.0.12 +eternalegypt==0.0.15 # homeassistant.components.eufylife_ble eufylife_ble_client==0.1.7 From 7098debe098a4c019c85222cfc73441ae6eda1f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 15:02:24 -1000 Subject: [PATCH 0802/1058] Fix sql doing I/O in the event loop at startup (#90335) * Fix sql doing I/O in the event loop * Fix sql doing I/O in the event loop * no test query on main db * fix mocking because it was targeting the recorder --- homeassistant/components/sql/sensor.py | 53 +++++++++++++++++--------- tests/components/sql/test_sensor.py | 16 +++++--- 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 95227bac65b0..57818ef27e4c 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -136,24 +136,17 @@ async def async_setup_sensor( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" - try: - engine = sqlalchemy.create_engine(db_url, future=True) - sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) - - # Run a dummy query just to test the db_url - sess: Session = sessmaker() - sess.execute(sqlalchemy.text("SELECT 1;")) - - except SQLAlchemyError as err: - _LOGGER.error( - "Couldn't connect using %s DB_URL: %s", - redact_credentials(db_url), - redact_credentials(str(err)), + instance = get_instance(hass) + sessmaker: scoped_session | None + if use_database_executor := (db_url == instance.db_url): + assert instance.engine is not None + sessmaker = scoped_session(sessionmaker(bind=instance.engine, future=True)) + elif not ( + sessmaker := await hass.async_add_executor_job( + _validate_and_get_session_maker_for_db_url, db_url ) + ): return - finally: - if sess: - sess.close() # MSSQL uses TOP and not LIMIT if not ("LIMIT" in query_str.upper() or "SELECT TOP" in query_str.upper()): @@ -162,8 +155,6 @@ async def async_setup_sensor( else: query_str = query_str.replace(";", "") + " LIMIT 1;" - use_database_executor = db_url == get_instance(hass).db_url - async_add_entities( [ SQLSensor( @@ -184,6 +175,32 @@ async def async_setup_sensor( ) +def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | None: + """Validate the db_url and return a session maker. + + This does I/O and should be run in the executor. + """ + try: + engine = sqlalchemy.create_engine(db_url, future=True) + sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) + # Run a dummy query just to test the db_url + sess: Session = sessmaker() + sess.execute(sqlalchemy.text("SELECT 1;")) + + except SQLAlchemyError as err: + _LOGGER.error( + "Couldn't connect using %s DB_URL: %s", + redact_credentials(db_url), + redact_credentials(str(err)), + ) + return None + else: + return sessmaker + finally: + if sess: + sess.close() + + class SQLSensor(SensorEntity): """Representation of an SQL sensor.""" diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 400e3056d5af..426dd9e196fb 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Any from unittest.mock import patch import pytest @@ -193,14 +194,19 @@ async def test_invalid_url_on_update( "column": "value", "name": "count_tables", } - await init_integration(hass, config) + + class MockSession: + """Mock session.""" + + def execute(self, query: Any) -> None: + """Execute the query.""" + raise SQLAlchemyError("sqlite://homeassistant:hunter2@homeassistant.local") with patch( - "homeassistant.components.sql.sensor.sqlalchemy.engine.cursor.CursorResult", - side_effect=SQLAlchemyError( - "sqlite://homeassistant:hunter2@homeassistant.local" - ), + "homeassistant.components.sql.sensor.scoped_session", + return_value=MockSession, ): + await init_integration(hass, config) async_fire_time_changed( hass, dt.utcnow() + timedelta(minutes=1), From c3717f8182d0eb7e176efc08ccb5be61d91b9a27 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Mar 2023 22:41:17 -0400 Subject: [PATCH 0803/1058] Clean up voice assistant integration (#90239) * Clean up voice assistant * Reinstate auto-removed imports * Resample STT audio from 44.1Khz to 16Khz * Energy based VAD for prototyping --------- Co-authored-by: Michael Hansen --- homeassistant/components/cloud/stt.py | 6 +- .../components/voice_assistant/pipeline.py | 150 ++++++------ .../voice_assistant/websocket_api.py | 62 ++++- .../snapshots/test_websocket.ambr | 210 +++++++++++++++++ .../voice_assistant/test_websocket.py | 216 +++++------------- 5 files changed, 407 insertions(+), 237 deletions(-) create mode 100644 tests/components/voice_assistant/snapshots/test_websocket.ambr diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index bdce055c3c44..13062db57d67 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import AsyncIterable +import logging from hass_nabucasa import Cloud from hass_nabucasa.voice import VoiceError @@ -20,6 +21,8 @@ from homeassistant.components.stt import ( from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + SUPPORT_LANGUAGES = [ "da-DK", "de-DE", @@ -102,7 +105,8 @@ class CloudProvider(Provider): result = await self.cloud.voice.process_stt( stream, content, metadata.language ) - except VoiceError: + except VoiceError as err: + _LOGGER.debug("Voice error: %s", err) return SpeechResult(None, SpeechResultState.ERROR) # Return Speech as Text diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 0070154bd40c..806a603f5e50 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -150,6 +150,7 @@ class PipelineRun: end_stage: PipelineStage event_callback: Callable[[PipelineEvent], None] language: str = None # type: ignore[assignment] + runner_data: Any | None = None def __post_init__(self): """Set language for pipeline.""" @@ -163,15 +164,14 @@ class PipelineRun: def start(self): """Emit run start event.""" - self.event_callback( - PipelineEvent( - PipelineEventType.RUN_START, - { - "pipeline": self.pipeline.name, - "language": self.language, - }, - ) - ) + data = { + "pipeline": self.pipeline.name, + "language": self.language, + } + if self.runner_data is not None: + data["runner_data"] = self.runner_data + + self.event_callback(PipelineEvent(PipelineEventType.RUN_START, data)) def end(self): """Emit run end event.""" @@ -200,41 +200,45 @@ class PipelineRun: try: # Load provider - stt_provider = stt.async_get_provider(self.hass, self.pipeline.stt_engine) + stt_provider: stt.Provider = stt.async_get_provider( + self.hass, self.pipeline.stt_engine + ) assert stt_provider is not None except Exception as src_error: - stt_error = SpeechToTextError( + _LOGGER.exception("No speech to text provider for %s", engine) + raise SpeechToTextError( code="stt-provider-missing", message=f"No speech to text provider for: {engine}", + ) from src_error + + if not stt_provider.check_metadata(metadata): + raise SpeechToTextError( + code="stt-provider-unsupported-metadata", + message=f"Provider {engine} does not support input speech to text metadata", ) - _LOGGER.exception(stt_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": stt_error.code, "message": stt_error.message}, - ) - ) - raise stt_error from src_error try: # Transcribe audio stream result = await stt_provider.async_process_audio_stream(metadata, stream) - assert (result.text is not None) and ( - result.result == stt.SpeechResultState.SUCCESS - ) except Exception as src_error: - stt_error = SpeechToTextError( + _LOGGER.exception("Unexpected error during speech to text") + raise SpeechToTextError( code="stt-stream-failed", message="Unexpected error during speech to text", + ) from src_error + + _LOGGER.debug("speech-to-text result %s", result) + + if result.result != stt.SpeechResultState.SUCCESS: + raise SpeechToTextError( + code="stt-stream-failed", + message="Speech to text failed", ) - _LOGGER.exception(stt_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": stt_error.code, "message": stt_error.message}, - ) + + if not result.text: + raise SpeechToTextError( + code="stt-no-text-recognized", message="No text recognized" ) - raise stt_error from src_error self.event_callback( PipelineEvent( @@ -273,18 +277,13 @@ class PipelineRun: agent_id=self.pipeline.conversation_engine, ) except Exception as src_error: - intent_error = IntentRecognitionError( + _LOGGER.exception("Unexpected error during intent recognition") + raise IntentRecognitionError( code="intent-failed", message="Unexpected error during intent recognition", - ) - _LOGGER.exception(intent_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": intent_error.code, "message": intent_error.message}, - ) - ) - raise intent_error from src_error + ) from src_error + + _LOGGER.debug("conversation result %s", conversation_result) self.event_callback( PipelineEvent( @@ -320,18 +319,13 @@ class PipelineRun: ), ) except Exception as src_error: - tts_error = TextToSpeechError( + _LOGGER.exception("Unexpected error during text to speech") + raise TextToSpeechError( code="tts-failed", message="Unexpected error during text to speech", - ) - _LOGGER.exception(tts_error.message) - self.event_callback( - PipelineEvent( - PipelineEventType.ERROR, - {"code": tts_error.code, "message": tts_error.message}, - ) - ) - raise tts_error from src_error + ) from src_error + + _LOGGER.debug("TTS result %s", tts_media) self.event_callback( PipelineEvent( @@ -377,31 +371,41 @@ class PipelineInput: run.start() current_stage = run.start_stage - # Speech to text - intent_input = self.intent_input - if current_stage == PipelineStage.STT: - assert self.stt_metadata is not None - assert self.stt_stream is not None - intent_input = await run.speech_to_text( - self.stt_metadata, - self.stt_stream, - ) - current_stage = PipelineStage.INTENT - - if run.end_stage != PipelineStage.STT: - tts_input = self.tts_input - - if current_stage == PipelineStage.INTENT: - assert intent_input is not None - tts_input = await run.recognize_intent( - intent_input, self.conversation_id + try: + # Speech to text + intent_input = self.intent_input + if current_stage == PipelineStage.STT: + assert self.stt_metadata is not None + assert self.stt_stream is not None + intent_input = await run.speech_to_text( + self.stt_metadata, + self.stt_stream, ) - current_stage = PipelineStage.TTS + current_stage = PipelineStage.INTENT - if run.end_stage != PipelineStage.INTENT: - if current_stage == PipelineStage.TTS: - assert tts_input is not None - await run.text_to_speech(tts_input) + if run.end_stage != PipelineStage.STT: + tts_input = self.tts_input + + if current_stage == PipelineStage.INTENT: + assert intent_input is not None + tts_input = await run.recognize_intent( + intent_input, self.conversation_id + ) + current_stage = PipelineStage.TTS + + if run.end_stage != PipelineStage.INTENT: + if current_stage == PipelineStage.TTS: + assert tts_input is not None + await run.text_to_speech(tts_input) + + except PipelineError as err: + run.event_callback( + PipelineEvent( + PipelineEventType.ERROR, + {"code": err.code, "message": err.message}, + ) + ) + return run.end() diff --git a/homeassistant/components/voice_assistant/websocket_api.py b/homeassistant/components/voice_assistant/websocket_api.py index cc4799f13e78..28cafb7a3556 100644 --- a/homeassistant/components/voice_assistant/websocket_api.py +++ b/homeassistant/components/voice_assistant/websocket_api.py @@ -1,5 +1,6 @@ """Voice Assistant Websocket API.""" import asyncio +import audioop from collections.abc import Callable import logging from typing import Any @@ -12,6 +13,8 @@ from homeassistant.core import HomeAssistant, callback from .pipeline import ( DEFAULT_TIMEOUT, PipelineError, + PipelineEvent, + PipelineEventType, PipelineInput, PipelineRun, PipelineStage, @@ -20,6 +23,10 @@ from .pipeline import ( _LOGGER = logging.getLogger(__name__) +_VAD_ENERGY_THRESHOLD = 1000 +_VAD_SPEECH_FRAMES = 25 +_VAD_SILENCE_FRAMES = 25 + @callback def async_register_websocket_api(hass: HomeAssistant) -> None: @@ -27,6 +34,17 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_run) +def _get_debiased_energy(audio_data: bytes, width: int = 2) -> float: + """Compute RMS of debiased audio.""" + energy = -audioop.rms(audio_data, width) + energy_bytes = bytes([energy & 0xFF, (energy >> 8) & 0xFF]) + debiased_energy = audioop.rms( + audioop.add(audio_data, energy_bytes * (len(audio_data) // width), width), width + ) + + return debiased_energy + + @websocket_api.websocket_command( { vol.Required("type"): "voice_assistant/run", @@ -49,6 +67,11 @@ async def websocket_run( ) -> None: """Run a pipeline.""" language = msg.get("language", hass.config.language) + + # Temporary workaround for language codes + if language == "en": + language = "en-US" + pipeline_id = msg.get("pipeline") pipeline = async_get_pipeline( hass, @@ -79,8 +102,32 @@ async def websocket_run( audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() async def stt_stream(): + state = None + speech_count = 0 + in_voice_command = False + # Yield until we receive an empty chunk while chunk := await audio_queue.get(): + chunk, state = audioop.ratecv(chunk, 2, 1, 44100, 16000, state) + is_speech = _get_debiased_energy(chunk) > _VAD_ENERGY_THRESHOLD + + if in_voice_command: + if is_speech: + speech_count += 1 + else: + speech_count -= 1 + + if speech_count <= -_VAD_SILENCE_FRAMES: + _LOGGER.info("Voice command stopped") + break + else: + if is_speech: + speech_count += 1 + + if speech_count >= _VAD_SPEECH_FRAMES: + in_voice_command = True + _LOGGER.info("Voice command started") + yield chunk def handle_binary(_hass, _connection, data: bytes): @@ -119,6 +166,9 @@ async def websocket_run( event_callback=lambda event: connection.send_event( msg["id"], event.as_dict() ), + runner_data={ + "stt_binary_handler_id": handler_id, + }, ), timeout=timeout, ) @@ -130,16 +180,20 @@ async def websocket_run( # Confirm subscription connection.send_result(msg["id"]) - if handler_id is not None: - # Send handler id to client - connection.send_event(msg["id"], {"handler_id": handler_id}) - try: # Task contains a timeout await run_task except PipelineError as error: # Report more specific error when possible connection.send_error(msg["id"], error.code, error.message) + except asyncio.TimeoutError: + connection.send_event( + msg["id"], + PipelineEvent( + PipelineEventType.ERROR, + {"code": "timeout", "message": "Timeout running pipeline"}, + ), + ) finally: if unregister_handler is not None: # Unregister binary handler diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/voice_assistant/snapshots/test_websocket.ambr new file mode 100644 index 000000000000..07934df6c4c6 --- /dev/null +++ b/tests/components/voice_assistant/snapshots/test_websocket.ambr @@ -0,0 +1,210 @@ +# serializer version: 1 +# name: test_audio_pipeline + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + }), + }) +# --- +# name: test_audio_pipeline.1 + dict({ + 'engine': 'default', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_audio_pipeline.2 + dict({ + 'stt_output': dict({ + 'text': 'test transcript', + }), + }) +# --- +# name: test_audio_pipeline.3 + dict({ + 'engine': 'default', + 'intent_input': 'test transcript', + }) +# --- +# name: test_audio_pipeline.4 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en-US', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_audio_pipeline.5 + dict({ + 'engine': 'default', + 'tts_input': "Sorry, I couldn't understand that", + }) +# --- +# name: test_audio_pipeline.6 + dict({ + 'tts_output': dict({ + 'mime_type': 'audio/mpeg', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_demo.mp3', + }), + }) +# --- +# name: test_intent_failed + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_intent_failed.1 + dict({ + 'engine': 'default', + 'intent_input': 'Are the lights on?', + }) +# --- +# name: test_intent_timeout + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_intent_timeout.1 + dict({ + 'engine': 'default', + 'intent_input': 'Are the lights on?', + }) +# --- +# name: test_intent_timeout.2 + dict({ + 'code': 'timeout', + 'message': 'Timeout running pipeline', + }) +# --- +# name: test_stt_provider_missing + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + }), + }) +# --- +# name: test_stt_provider_missing.1 + dict({ + 'engine': 'default', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_stt_stream_failed + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + }), + }) +# --- +# name: test_stt_stream_failed.1 + dict({ + 'engine': 'default', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'language': 'en-US', + 'sample_rate': 16000, + }), + }) +# --- +# name: test_text_only_pipeline + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_text_only_pipeline.1 + dict({ + 'engine': 'default', + 'intent_input': 'Are the lights on?', + }) +# --- +# name: test_text_only_pipeline.2 + dict({ + 'intent_output': dict({ + 'conversation_id': None, + 'response': dict({ + 'card': dict({ + }), + 'data': dict({ + 'code': 'no_intent_match', + }), + 'language': 'en-US', + 'response_type': 'error', + 'speech': dict({ + 'plain': dict({ + 'extra_data': None, + 'speech': "Sorry, I couldn't understand that", + }), + }), + }), + }), + }) +# --- +# name: test_text_pipeline_timeout + dict({ + 'code': 'timeout', + 'message': 'Timeout running pipeline', + }) +# --- +# name: test_tts_failed + dict({ + 'language': 'en-US', + 'pipeline': 'en-US', + 'runner_data': dict({ + 'stt_binary_handler_id': None, + }), + }) +# --- +# name: test_tts_failed.1 + dict({ + 'engine': 'default', + 'tts_input': 'Lights are on.', + }) +# --- diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index a1ba8b5f7cbb..f02122a3e7fa 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterable from unittest.mock import MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import stt from homeassistant.core import HomeAssistant @@ -29,7 +30,7 @@ class MockSttProvider(stt.Provider): @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" - return [self.hass.config.language] + return ["en-US"] @property def supported_formats(self) -> list[stt.AudioFormats]: @@ -64,7 +65,11 @@ class MockSttProvider(stt.Provider): @pytest.fixture(autouse=True) -async def init_components(hass): +async def init_components( + hass: HomeAssistant, + mock_get_cache_files, # noqa: F811 + mock_init_cache_dir, # noqa: F811 +): """Initialize relevant components with empty configs.""" assert await async_setup_component(hass, "media_source", {}) assert await async_setup_component( @@ -93,6 +98,7 @@ async def init_components(hass): async def test_text_only_pipeline( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with text input (no STT/TTS).""" client = await hass_ws_client(hass) @@ -114,38 +120,16 @@ async def test_text_only_pipeline( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # intent msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": "Are the lights on?", - } + assert msg["event"]["data"] == snapshot msg = await client.receive_json() assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == { - "intent_output": { - "response": { - "speech": { - "plain": { - "speech": "Sorry, I couldn't understand that", - "extra_data": None, - } - }, - "card": {}, - "language": "en", - "response_type": "error", - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } - } + assert msg["event"]["data"] == snapshot # run end msg = await client.receive_json() @@ -154,8 +138,7 @@ async def test_text_only_pipeline( async def test_audio_pipeline( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion ) -> None: """Test events from a pipeline run with audio input/output.""" client = await hass_ws_client(hass) @@ -173,86 +156,40 @@ async def test_audio_pipeline( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # stt msg = await client.receive_json() assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == { - "engine": "default", - "metadata": { - "bit_rate": 16, - "channel": 1, - "codec": "pcm", - "format": "wav", - "language": "en", - "sample_rate": 16000, - }, - } + assert msg["event"]["data"] == snapshot # End of audio stream (handler id + empty payload) await client.send_bytes(b"1") msg = await client.receive_json() assert msg["event"]["type"] == "stt-end" - assert msg["event"]["data"] == { - "stt_output": {"text": _TRANSCRIPT}, - } + assert msg["event"]["data"] == snapshot # intent msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": _TRANSCRIPT, - } + assert msg["event"]["data"] == snapshot msg = await client.receive_json() assert msg["event"]["type"] == "intent-end" - assert msg["event"]["data"] == { - "intent_output": { - "response": { - "speech": { - "plain": { - "speech": "Sorry, I couldn't understand that", - "extra_data": None, - } - }, - "card": {}, - "language": "en", - "response_type": "error", - "data": {"code": "no_intent_match"}, - }, - "conversation_id": None, - } - } + assert msg["event"]["data"] == snapshot # text to speech msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == { - "engine": "default", - "tts_input": "Sorry, I couldn't understand that", - } + assert msg["event"]["data"] == snapshot msg = await client.receive_json() assert msg["event"]["type"] == "tts-end" - assert msg["event"]["data"] == { - "tts_output": { - "url": f"/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_{hass.config.language}_-_demo.mp3", - "mime_type": "audio/mpeg", - }, - } + assert msg["event"]["data"] == snapshot # run end msg = await client.receive_json() @@ -261,7 +198,10 @@ async def test_audio_pipeline( async def test_intent_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test partial pipeline run with conversation agent timeout.""" client = await hass_ws_client(hass) @@ -291,27 +231,24 @@ async def test_intent_timeout( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # intent msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": "Are the lights on?", - } + assert msg["event"]["data"] == snapshot # timeout error msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "timeout" + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot async def test_text_pipeline_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test text-only pipeline run with immediate timeout.""" client = await hass_ws_client(hass) @@ -340,12 +277,15 @@ async def test_text_pipeline_timeout( # timeout error msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "timeout" + assert msg["event"]["type"] == "error" + assert msg["event"]["data"] == snapshot async def test_intent_failed( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test text-only pipeline run with conversation agent error.""" client = await hass_ws_client(hass) @@ -371,18 +311,12 @@ async def test_intent_failed( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # intent start msg = await client.receive_json() assert msg["event"]["type"] == "intent-start" - assert msg["event"]["data"] == { - "engine": "default", - "intent_input": "Are the lights on?", - } + assert msg["event"]["data"] == snapshot # intent error msg = await client.receive_json() @@ -391,7 +325,10 @@ async def test_intent_failed( async def test_audio_pipeline_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test audio pipeline run with immediate timeout.""" client = await hass_ws_client(hass) @@ -417,19 +354,16 @@ async def test_audio_pipeline_timeout( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # timeout error msg = await client.receive_json() - assert not msg["success"] - assert msg["error"]["code"] == "timeout" + assert msg["event"]["type"] == "error" + assert msg["event"]["data"]["code"] == "timeout" async def test_stt_provider_missing( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with a non-existent STT provider.""" with patch( @@ -451,32 +385,15 @@ async def test_stt_provider_missing( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # stt msg = await client.receive_json() assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == { - "engine": "default", - "metadata": { - "bit_rate": 16, - "channel": 1, - "codec": "pcm", - "format": "wav", - "language": "en", - "sample_rate": 16000, - }, - } + assert msg["event"]["data"] == snapshot # End of audio stream (handler id + empty payload) await client.send_bytes(b"1") @@ -490,6 +407,7 @@ async def test_stt_provider_missing( async def test_stt_stream_failed( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test events from a pipeline run with a non-existent STT provider.""" with patch( @@ -511,32 +429,15 @@ async def test_stt_stream_failed( msg = await client.receive_json() assert msg["success"] - # handler id - msg = await client.receive_json() - assert msg["event"]["handler_id"] == 1 - # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # stt msg = await client.receive_json() assert msg["event"]["type"] == "stt-start" - assert msg["event"]["data"] == { - "engine": "default", - "metadata": { - "bit_rate": 16, - "channel": 1, - "codec": "pcm", - "format": "wav", - "language": "en", - "sample_rate": 16000, - }, - } + assert msg["event"]["data"] == snapshot # End of audio stream (handler id + empty payload) await client.send_bytes(b"1") @@ -548,7 +449,10 @@ async def test_stt_stream_failed( async def test_tts_failed( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + snapshot: SnapshotAssertion, ) -> None: """Test pipeline run with text to speech error.""" client = await hass_ws_client(hass) @@ -574,18 +478,12 @@ async def test_tts_failed( # run start msg = await client.receive_json() assert msg["event"]["type"] == "run-start" - assert msg["event"]["data"] == { - "pipeline": hass.config.language, - "language": hass.config.language, - } + assert msg["event"]["data"] == snapshot # tts start msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" - assert msg["event"]["data"] == { - "engine": "default", - "tts_input": "Lights are on.", - } + assert msg["event"]["data"] == snapshot # tts error msg = await client.receive_json() From 624860da0e9a49381571ef91788b863181387d0b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 Mar 2023 08:05:30 +0200 Subject: [PATCH 0804/1058] Remove deprecated platform yaml in Scrape (#90272) * Deprecate platform yaml * typing * DiscoveryInfoType --- homeassistant/components/scrape/sensor.py | 89 ++------------------ homeassistant/components/scrape/strings.json | 6 -- tests/components/scrape/__init__.py | 54 +----------- tests/components/scrape/test_sensor.py | 67 +-------------- 4 files changed, 8 insertions(+), 208 deletions(-) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 22184a17b803..5ddd6c48e433 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -1,44 +1,23 @@ """Support for getting data from websites with scraping.""" from __future__ import annotations -from datetime import timedelta import logging -from typing import Any +from typing import Any, cast import voluptuous as vol -from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config -from homeassistant.components.sensor import ( - CONF_STATE_CLASS, - DEVICE_CLASSES_SCHEMA, - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - STATE_CLASSES_SCHEMA, - SensorDeviceClass, -) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, - CONF_AUTHENTICATION, - CONF_DEVICE_CLASS, - CONF_HEADERS, CONF_NAME, - CONF_PASSWORD, - CONF_RESOURCE, - CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, - CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -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.template import Template from homeassistant.helpers.template_entity import ( TEMPLATE_SENSOR_BASE_SCHEMA, @@ -47,43 +26,11 @@ from homeassistant.helpers.template_entity import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - CONF_INDEX, - CONF_SELECT, - DEFAULT_NAME, - DEFAULT_SCAN_INTERVAL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import CONF_INDEX, CONF_SELECT, DOMAIN from .coordinator import ScrapeCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - # Linked to the loading of the page (can be linked to RestData) - vol.Optional(CONF_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), - vol.Optional(CONF_PASSWORD): cv.string, - vol.Required(CONF_RESOURCE): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - # Linked to the parsing of the page (specific to scrape) - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_INDEX, default=0): cv.positive_int, - vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - # Linked to the sensor definition (can be linked to TemplateSensor) - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - async def async_setup_platform( hass: HomeAssistant, @@ -92,33 +39,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Web scrape sensor.""" - coordinator: ScrapeCoordinator - sensors_config: list[ConfigType] - if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "moved_yaml", - breaks_in_ha_version="2022.12.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="moved_yaml", - ) - resource_config = vol.Schema(RESOURCE_SCHEMA, extra=vol.REMOVE_EXTRA)(config) - rest = create_rest_data_from_config(hass, resource_config) - - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinator = ScrapeCoordinator(hass, rest, scan_interval) - - sensors_config = [ - vol.Schema(TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA)( - config - ) - ] - - else: - coordinator = discovery_info["coordinator"] - sensors_config = discovery_info["configs"] + discovery_info = cast(DiscoveryInfoType, discovery_info) + coordinator: ScrapeCoordinator = discovery_info["coordinator"] + sensors_config: list[ConfigType] = discovery_info["configs"] await coordinator.async_refresh() if coordinator.data is None: diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 907aa2a9dfdf..061518cb1dbf 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -121,11 +121,5 @@ } } } - }, - "issues": { - "moved_yaml": { - "title": "The Scrape YAML configuration has been moved", - "description": "Configuring Scrape using YAML has been moved to integration key.\n\nYour existing YAML configuration will be working for 2 more versions.\n\nMigrate your YAML configuration to the integration key according to the documentation." - } } } diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index 1bf3040513f6..3d57970a5288 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -29,65 +29,13 @@ def return_integration_config( return config -def return_config( - select, - name, - *, - attribute=None, - index=None, - template=None, - uom=None, - device_class=None, - state_class=None, - authentication=None, - username=None, - password=None, - headers=None, - unique_id=None, - remove_platform=False, -) -> dict[str, dict[str, Any]]: - """Return config.""" - config = { - "platform": "scrape", - "resource": "https://www.home-assistant.io", - "select": select, - "name": name, - "index": 0, - "verify_ssl": True, - } - if remove_platform: - config.pop("platform") - if attribute: - config["attribute"] = attribute - if index: - config["index"] = index - if template: - config["value_template"] = template - if uom: - config["unit_of_measurement"] = uom - if device_class: - config["device_class"] = device_class - if state_class: - config["state_class"] = state_class - if authentication: - config["authentication"] = authentication - if username: - config["username"] = username - config["password"] = password - if headers: - config["headers"] = headers - if unique_id: - config["unique_id"] = unique_id - return config - - class MockRestData: """Mock RestData.""" def __init__( self, payload, - ): + ) -> None: """Init RestDataMock.""" self.data: str | None = None self.payload = payload diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index bc13e01d25c0..44c264520d6f 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -9,7 +9,6 @@ import pytest from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL from homeassistant.components.sensor import ( CONF_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) @@ -24,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from . import MockRestData, return_config, return_integration_config +from . import MockRestData, return_integration_config from tests.common import MockConfigEntry, async_fire_time_changed @@ -53,70 +52,6 @@ async def test_scrape_sensor(hass: HomeAssistant) -> None: assert state.state == "Current Version: 2021.12.10" -async def test_scrape_sensor_platform_yaml(hass: HomeAssistant) -> None: - """Test Scrape sensor load from sensor platform.""" - config = { - SENSOR_DOMAIN: [ - return_config( - select=".return", - name="Auth page", - username="user@secret.com", - password="12345678", - authentication="digest", - ), - return_config( - select=".return", - name="Auth page2", - username="user@secret.com", - password="12345678", - template="{{value}}", - ), - ] - } - - mocker = MockRestData("test_scrape_sensor_authentication") - with patch( - "homeassistant.components.rest.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.auth_page") - assert state.state == "secret text" - state2 = hass.states.get("sensor.auth_page2") - assert state2.state == "secret text" - - -async def test_scrape_sensor_platform_yaml_no_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test Scrape sensor load from sensor platform fetching no data.""" - config = { - SENSOR_DOMAIN: [ - return_config( - select=".return", - name="Auth page", - username="user@secret.com", - password="12345678", - authentication="digest", - ), - ] - } - - mocker = MockRestData("test_scrape_sensor_no_data") - with patch( - "homeassistant.components.rest.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.auth_page") - assert not state - assert "Platform scrape not ready yet: None; Retrying in background" in caplog.text - - async def test_scrape_sensor_value_template(hass: HomeAssistant) -> None: """Test Scrape sensor with value template.""" config = { From a773c37190cba72e4fc772fc7611225f291f69d2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 08:33:46 +0200 Subject: [PATCH 0805/1058] Cleanup name assignment imap sensor (#90306) --- homeassistant/components/imap/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index 4dc0c0fffbe2..776abc174a2c 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_USERNAME +from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -41,10 +41,6 @@ class ImapSensor( ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - # To be removed when YAML import is removed - if CONF_NAME in coordinator.config_entry.data: - self._attr_name = coordinator.config_entry.data[CONF_NAME] - self._attr_has_entity_name = False self._attr_unique_id = f"{coordinator.config_entry.entry_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, From 6a5c05e7d2290ebc5d41d0abbe4be0ece1522e3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Mar 2023 21:34:28 -1000 Subject: [PATCH 0806/1058] Add support for clearing the on device GATT cache to esphome (#90318) --- .../components/esphome/bluetooth/client.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index c332fc3441e1..914021b467ef 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -44,6 +44,7 @@ CCCD_INDICATE_BYTES = b"\x02\x00" MIN_BLUETOOTH_PROXY_VERSION_HAS_CACHE = 3 MIN_BLUETOOTH_PROXY_HAS_PAIRING = 4 +MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE = 5 DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE _LOGGER = logging.getLogger(__name__) @@ -518,10 +519,28 @@ class ESPHomeClient(BaseBleakClient): raise BleakError(f"Characteristic {char_specifier} was not found!") return characteristic - async def clear_cache(self) -> None: + @api_error_as_bleak_error + async def clear_cache(self) -> bool: """Clear the GATT cache.""" self.domain_data.clear_gatt_services_cache(self._address_as_int) self.domain_data.clear_gatt_mtu_cache(self._address_as_int) + if self._connection_version < MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE: + _LOGGER.warning( + "On device cache clear is not available with ESPHome Bluetooth version %s, " + "version %s is needed; Only memory cache will be cleared", + self._connection_version, + MIN_BLUETOOTH_PROXY_HAS_CLEAR_CACHE, + ) + return True + response = await self._client.bluetooth_device_clear_cache(self._address_as_int) + if response.success: + return True + _LOGGER.error( + "Clear cache failed with %s failed due to error: %s", + self.address, + response.error, + ) + return False @verify_connected @api_error_as_bleak_error From 164482dc089fd649d2862e57d425922fb16ea7eb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:00:41 +0200 Subject: [PATCH 0807/1058] Use lambda in gree switch (#90316) --- homeassistant/components/gree/switch.py | 77 +++++++++++++++++++++---- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 0ac740671344..01f98b996ddd 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -1,8 +1,12 @@ """Support for interface with a Gree climate systems.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any, cast +from greeclimate.device import Device + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -16,25 +20,77 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN from .entity import GreeEntity -GREE_SWITCHES: tuple[SwitchEntityDescription, ...] = ( - SwitchEntityDescription( + +@dataclass +class GreeRequiredKeysMixin: + """Mixin for required keys.""" + + get_value_fn: Callable[[Device], bool] + set_value_fn: Callable[[Device, bool], None] + + +@dataclass +class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): + """Describes Gree switch entity.""" + + +def _set_light(device: Device, value: bool) -> None: + """Typed helper to set device light property.""" + device.light = value + + +def _set_quiet(device: Device, value: bool) -> None: + """Typed helper to set device quiet property.""" + device.quiet = value + + +def _set_fresh_air(device: Device, value: bool) -> None: + """Typed helper to set device fresh_air property.""" + device.fresh_air = value + + +def _set_xfan(device: Device, value: bool) -> None: + """Typed helper to set device xfan property.""" + device.xfan = value + + +def _set_anion(device: Device, value: bool) -> None: + """Typed helper to set device anion property.""" + device.anion = value + + +GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( + GreeSwitchEntityDescription( icon="mdi:lightbulb", name="Panel Light", key="light", + get_value_fn=lambda d: d.light, + set_value_fn=_set_light, ), - SwitchEntityDescription( + GreeSwitchEntityDescription( name="Quiet", key="quiet", + get_value_fn=lambda d: d.quiet, + set_value_fn=_set_quiet, ), - SwitchEntityDescription( + GreeSwitchEntityDescription( name="Fresh Air", key="fresh_air", + get_value_fn=lambda d: d.fresh_air, + set_value_fn=_set_fresh_air, ), - SwitchEntityDescription(name="XFan", key="xfan"), - SwitchEntityDescription( + GreeSwitchEntityDescription( + name="XFan", + key="xfan", + get_value_fn=lambda d: d.xfan, + set_value_fn=_set_xfan, + ), + GreeSwitchEntityDescription( icon="mdi:pine-tree", name="Health mode", key="anion", + get_value_fn=lambda d: d.anion, + set_value_fn=_set_anion, entity_registry_enabled_default=False, ), ) @@ -68,8 +124,9 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Generic Gree switch entity.""" _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: GreeSwitchEntityDescription - def __init__(self, coordinator, description: SwitchEntityDescription) -> None: + def __init__(self, coordinator, description: GreeSwitchEntityDescription) -> None: """Initialize the Gree device.""" self.entity_description = description @@ -78,16 +135,16 @@ class GreeSwitch(GreeEntity, SwitchEntity): @property def is_on(self) -> bool: """Return if the state is turned on.""" - return getattr(self.coordinator.device, self.entity_description.key) + return self.entity_description.get_value_fn(self.coordinator.device) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - setattr(self.coordinator.device, self.entity_description.key, True) + self.entity_description.set_value_fn(self.coordinator.device, True) await self.coordinator.push_state_update() self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - setattr(self.coordinator.device, self.entity_description.key, False) + self.entity_description.set_value_fn(self.coordinator.device, False) await self.coordinator.push_state_update() self.async_write_ha_state() From 8c519e1abb1468256c028a04c0c68f84d5cee68a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:01:39 +0200 Subject: [PATCH 0808/1058] Use SnapshotAssertion in gree climate tests (#90339) --- tests/components/gree/common.py | 7 +- .../gree/snapshots/test_climate.ambr | 118 ++++++++++++++++++ tests/components/gree/test_climate.py | 37 +++--- 3 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 tests/components/gree/snapshots/test_climate.ambr diff --git a/tests/components/gree/common.py b/tests/components/gree/common.py index cd8a2d6ee28d..aa88688486c3 100644 --- a/tests/components/gree/common.py +++ b/tests/components/gree/common.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock from greeclimate.discovery import Listener from homeassistant.components.gree.const import DISCOVERY_TIMEOUT, DOMAIN as GREE_DOMAIN +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -90,8 +91,10 @@ def build_device_mock(name="fake-device-1", ipAddress="1.1.1.1", mac="aabbcc1122 return mock -async def async_setup_gree(hass): +async def async_setup_gree(hass: HomeAssistant) -> MockConfigEntry: """Set up the gree platform.""" - MockConfigEntry(domain=GREE_DOMAIN).add_to_hass(hass) + entry = MockConfigEntry(domain=GREE_DOMAIN) + entry.add_to_hass(hass) await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {"climate": {}}}) await hass.async_block_till_done() + return entry diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr new file mode 100644 index 000000000000..f1479cad3d38 --- /dev/null +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -0,0 +1,118 @@ +# serializer version: 1 +# name: test_entity_states + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 25, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'auto', + 'low', + 'medium low', + 'medium', + 'medium high', + 'high', + ]), + 'friendly_name': 'fake-device-1', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 8, + 'preset_mode': 'none', + 'preset_modes': list([ + 'eco', + 'away', + 'boost', + 'none', + 'sleep', + ]), + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'target_temp_step': 1, + 'temperature': 25, + }), + 'context': , + 'entity_id': 'climate.fake_device_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_registry_settings + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium low', + 'medium', + 'medium high', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 8, + 'preset_modes': list([ + 'eco', + 'away', + 'boost', + 'none', + 'sleep', + ]), + 'swing_modes': list([ + 'off', + 'vertical', + 'horizontal', + 'both', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.fake_device_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'fake-device-1', + 'platform': 'gree', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'aabbcc112233', + 'unit_of_measurement': None, + }), + ]) +# --- diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 16b6d0cf3ad7..afed01c1a085 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -5,6 +5,7 @@ from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, patch from greeclimate.device import HorizontalSwing, VerticalSwing from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -31,15 +32,12 @@ from homeassistant.components.climate import ( SWING_HORIZONTAL, SWING_OFF, SWING_VERTICAL, - ClimateEntityFeature, HVACMode, ) from homeassistant.components.gree.climate import FAN_MODES_REVERSE, HVAC_MODES_REVERSE from homeassistant.components.gree.const import FAN_MEDIUM_HIGH, FAN_MEDIUM_LOW from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -47,6 +45,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .common import async_setup_gree, build_device_mock @@ -797,22 +796,20 @@ async def test_update_swing_mode( assert state.attributes.get(ATTR_SWING_MODE) == swing_mode -async def test_name(hass: HomeAssistant, discovery, device) -> None: - """Test for name property.""" - await async_setup_gree(hass) - state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake-device-1" - - -async def test_supported_features_with_turnon( - hass: HomeAssistant, discovery, device +@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +async def test_registry_settings( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: - """Test for supported_features property.""" + """Test for entity registry settings (unique_id).""" + entry = await async_setup_gree(hass) + + entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert entries == snapshot + + +@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +async def test_entity_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test for entity registry settings (unique_id).""" await async_setup_gree(hass) - state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.SWING_MODE - ) + states = hass.states.async_all() + assert states == snapshot From 94a52d5ccad9dcf1151b539481dd78abb7983e12 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:00:23 +0200 Subject: [PATCH 0809/1058] Adjust tts default_options type hints (#90053) * Adjust tts default_options type hints * Improve other components * Adjust * Revert component changes * Adjust get_tts_audio in amazon_polly --- homeassistant/components/amazon_polly/tts.py | 6 +++--- homeassistant/components/tts/__init__.py | 12 +++++++----- pylint/plugins/hass_enforce_type_hints.py | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 7e21b9ac603d..97e0af7f18ed 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Final +from typing import Any, Final import boto3 import botocore @@ -166,8 +166,8 @@ class AmazonPollyProvider(Provider): def get_tts_audio( self, message: str, - language: str | None = None, - options: dict[str, str] | None = None, + language: str, + options: dict[str, Any] | None = None, ) -> TtsAudioType: """Request TTS file from Polly.""" if options is None or language is None: diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 39aedfe8cbd3..aa8864ad23da 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import functools as ft import hashlib from http import HTTPStatus @@ -380,11 +381,12 @@ class SpeechManager: raise HomeAssistantError(f"Not supported language {language}") # Options - if provider.default_options and options: - merged_options = provider.default_options.copy() + if (default_options := provider.default_options) and options: + merged_options = dict(default_options) merged_options.update(options) options = merged_options - options = options or provider.default_options + if not options: + options = None if default_options is None else dict(default_options) if options is not None: supported_options = provider.supported_options or [] @@ -665,8 +667,8 @@ class Provider: return None @property - def default_options(self) -> dict[str, Any] | None: - """Return a dict include default options.""" + def default_options(self) -> Mapping[str, Any] | None: + """Return a mapping with the default options.""" return None def get_tts_audio( diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 63cc5a1c6b92..58bfd3b69b13 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2366,7 +2366,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="default_options", - return_type=["dict[str, Any]", None], + return_type=["Mapping[str, Any]", None], ), TypeHintMatch( function_name="get_tts_audio", From 56293ad876884fb3c49ecf4d2e18cdcc82e95452 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Mon, 27 Mar 2023 11:00:52 +0200 Subject: [PATCH 0810/1058] Revert "Fix NMBS IndexError" (#90346) --- homeassistant/components/nmbs/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index c3bcdb355367..b9a216875f4b 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -162,11 +162,7 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if ( - liveboard is None - or not liveboard.get("departures") - or liveboard.get("number") == "0" - ): + if liveboard is None or not liveboard.get("departures"): return next_departure = liveboard["departures"]["departure"][0] From 97f8a3fdcd126814ede5594e586748823ec0c800 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 27 Mar 2023 11:04:03 +0200 Subject: [PATCH 0811/1058] Reolink add auto tracking entities (#90063) --- homeassistant/components/reolink/number.py | 54 +++++++++++++++++++ homeassistant/components/reolink/select.py | 13 ++++- homeassistant/components/reolink/strings.json | 7 +++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 4a221e2ca9d7..bb19974114d5 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -188,6 +188,60 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.quick_reply_time(ch), method=lambda api, ch, value: api.set_quick_reply(ch, time=int(value)), ), + ReolinkNumberEntityDescription( + key="auto_track_limit_left", + name="Auto track limit left", + icon="mdi:angle-acute", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=-1, + native_max_value=2700, + supported=lambda api, ch: api.supported(ch, "auto_track_limit"), + value=lambda api, ch: api.auto_track_limit_left(ch), + method=lambda api, ch, value: api.set_auto_track_limit(ch, left=int(value)), + ), + ReolinkNumberEntityDescription( + key="auto_track_limit_right", + name="Auto track limit right", + icon="mdi:angle-acute", + mode=NumberMode.SLIDER, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=-1, + native_max_value=2700, + supported=lambda api, ch: api.supported(ch, "auto_track_limit"), + value=lambda api, ch: api.auto_track_limit_right(ch), + method=lambda api, ch, value: api.set_auto_track_limit(ch, right=int(value)), + ), + ReolinkNumberEntityDescription( + key="auto_track_disappear_time", + name="Auto track disappear time", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "auto_track_disappear_time"), + value=lambda api, ch: api.auto_track_disappear_time(ch), + method=lambda api, ch, value: api.set_auto_tracking( + ch, disappear_time=int(value) + ), + ), + ReolinkNumberEntityDescription( + key="auto_track_stop_time", + name="Auto track stop time", + icon="mdi:target-account", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=1, + native_max_value=60, + supported=lambda api, ch: api.supported(ch, "auto_track_stop_time"), + value=lambda api, ch: api.auto_track_stop_time(ch), + method=lambda api, ch, value: api.set_auto_tracking(ch, stop_time=int(value)), + ), ) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index e18961c97d43..a994b7d353b6 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -5,7 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum +from reolink_aio.api import DayNightEnum, Host, SpotlightModeEnum, TrackMethodEnum from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -79,6 +79,17 @@ SELECT_ENTITIES = ( ch, file_id=[k for k, v in api.quick_reply_dict(ch).items() if v == mess][0] ), ), + ReolinkSelectEntityDescription( + key="auto_track_method", + name="Auto track method", + icon="mdi:target-account", + translation_key="auto_track_method", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in TrackMethodEnum], + supported=lambda api, ch: api.supported(ch, "auto_track_method"), + value=lambda api, ch: TrackMethodEnum(api.auto_track_method(ch)).name, + method=lambda api, ch, name: api.set_auto_tracking(ch, method=name), + ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 06b588a119c7..74759c12f988 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -72,6 +72,13 @@ "state": { "off": "Off" } + }, + "auto_track_method": { + "state": { + "digital": "Digital", + "digitalfirst": "Digital first", + "pantiltfirst": "Pan/tilt first" + } } } } From 0d5864682390e7f10d131d58697b3ef38cd4564a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 27 Mar 2023 11:11:38 +0200 Subject: [PATCH 0812/1058] Bump reolink-aio to 0.5.7 (#90344) --- 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 7050ed61d504..95b180fc164c 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.5.6"] + "requirements": ["reolink-aio==0.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 31e78c40c201..fafe16fb7867 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.6 +reolink-aio==0.5.7 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3be11d6cd6eb..93bd0673832e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.6 +reolink-aio==0.5.7 # homeassistant.components.python_script restrictedpython==6.0 From 5b3c57ff1e954273b7e24d600697bcbd98321349 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 11:47:22 +0200 Subject: [PATCH 0813/1058] Add option flow for imap integration (#89914) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/imap/config_flow.py | 74 ++++++++++++- homeassistant/components/imap/strings.json | 18 +++ tests/components/imap/conftest.py | 14 +++ tests/components/imap/test_config_flow.py | 110 ++++++++++++++++--- 4 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 tests/components/imap/conftest.py diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index de1ac1e5d659..c855d099b4ad 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -36,6 +37,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FOLDER, default="INBOX"): str, + vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, + } +) + async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: """Validate user input.""" @@ -80,9 +88,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( { - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_FOLDER: user_input[CONF_FOLDER], - CONF_SEARCH: user_input[CONF_SEARCH], + key: user_input[key] + for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) } ) @@ -128,3 +135,64 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + @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.OptionsFlowWithConfigEntry): + """Option flow handler.""" + + def _async_abort_entries_match( + self, match_dict: dict[str, Any] | None + ) -> dict[str, str]: + """Validate the user input against other config entries.""" + if match_dict is None: + return {} + + errors: dict[str, str] = {} + for entry in [ + entry + for entry in self.hass.config_entries.async_entries(DOMAIN) + if entry is not self.config_entry + ]: + if all(item in entry.data.items() for item in match_dict.items()): + errors["base"] = "already_configured" + break + return errors + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = self._async_abort_entries_match( + { + CONF_SERVER: self._config_entry.data[CONF_SERVER], + CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_FOLDER: user_input[CONF_FOLDER], + CONF_SEARCH: user_input[CONF_SEARCH], + } + if user_input + else None + ) + entry_data: dict[str, Any] = dict(self._config_entry.data) + if not errors and user_input is not None: + entry_data.update(user_input) + errors = await validate_input(entry_data) + if not errors: + self.hass.config_entries.async_update_entry( + self.config_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + return self.async_create_entry(data={}) + + schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) + + return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index bb03f82bb76d..d104f591c638 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -31,5 +31,23 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "init": { + "data": { + "folder": "[%key:component::imap::config::step::user::data::folder%]", + "search": "[%key:component::imap::config::step::user::data::search%]" + } + } + }, + "error": { + "already_configured": "An entry with these folder and search options already exists", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", + "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", + "invalid_search": "[%key:component::imap::config::error::invalid_search%]" + } } } diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py new file mode 100644 index 000000000000..bc82cf57d816 --- /dev/null +++ b/tests/components/imap/conftest.py @@ -0,0 +1,14 @@ +"""Test the iamp config flow.""" +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.imap.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 663637ff0ba8..20c9ddf8938f 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -1,11 +1,11 @@ """Test the imap config flow.""" import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aioimaplib import AioImapException import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.imap.const import ( CONF_CHARSET, CONF_FOLDER, @@ -29,8 +29,15 @@ MOCK_CONFIG = { "search": "UnSeen UnDeleted", } +MOCK_OPTIONS = { + "folder": "INBOX", + "search": "UnSeen UnDeleted", +} -async def test_form(hass: HomeAssistant) -> None: +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +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} @@ -40,10 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_client: mock_client.return_value.search.return_value = ( "OK", [b""], @@ -184,10 +188,7 @@ async def test_form_invalid_search(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" ) as mock_client: - mock_client.return_value.search.return_value = ( - "BAD", - [b"Invalid search"], - ) + mock_client.return_value.search.return_value = ("BAD", [b"Invalid search"]) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG ) @@ -196,7 +197,7 @@ async def test_form_invalid_search(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_SEARCH: "invalid_search"} -async def test_reauth_success(hass: HomeAssistant) -> None: +async def test_reauth_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we can reauth.""" entry = MockConfigEntry( domain=DOMAIN, @@ -219,10 +220,7 @@ async def test_reauth_success(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_client: mock_client.return_value.search.return_value = ( "OK", [b""], @@ -310,3 +308,83 @@ async def test_reauth_failed_conn_error(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_options_form(hass: HomeAssistant) -> None: + """Test we show the options form.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + new_config["folder"] = "INBOX.Notifications" + new_config["search"] = "UnSeen UnDeleted!!INVALID" + + # simulate initial search setup error + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("BAD", [b"Invalid search"]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], new_config + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {CONF_SEARCH: "invalid_search"} + + new_config["search"] = "UnSeen UnDeleted" + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result3["data"] == {} + for key, value in new_config.items(): + assert entry.data[key] == value + + +async def test_key_options_in_options_form(hass: HomeAssistant) -> None: + """Test we cannot change options if that would cause duplicates.""" + + entry1 = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry1.add_to_hass(hass) + await hass.config_entries.async_setup(entry1.entry_id) + + config2 = MOCK_CONFIG.copy() + config2["folder"] = "INBOX.Notifications" + entry2 = MockConfigEntry(domain=DOMAIN, data=config2) + entry2.add_to_hass(hass) + await hass.config_entries.async_setup(entry2.entry_id) + + # Now try to set back the folder option of entry2 + # so that it conflicts with that of entry1 + result = await hass.config_entries.options.async_init(entry2.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "already_configured"} From d9471fd01a3abdedc230c650f97e18e76f9c4a79 Mon Sep 17 00:00:00 2001 From: Joel Goguen Date: Mon, 27 Mar 2023 06:14:16 -0400 Subject: [PATCH 0814/1058] Bump python-holidays to 0.21.13 (#89724) Update Python holidays module to 0.21.13 python-holidays 0.19-0.21.13 adds support for new countries and enhances support for many currently supported countries. --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 4c1014140690..c9299b21ce14 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -11,5 +11,5 @@ "korean_lunar_calendar" ], "quality_scale": "internal", - "requirements": ["holidays==0.18.0"] + "requirements": ["holidays==0.21.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index fafe16fb7867..134bc6f45969 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -904,7 +904,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.18.0 +holidays==0.21.13 # homeassistant.components.frontend home-assistant-frontend==20230309.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93bd0673832e..977c2896c0af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -690,7 +690,7 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.18.0 +holidays==0.21.13 # homeassistant.components.frontend home-assistant-frontend==20230309.1 From c11a3881af58d681a27e1402d54de4e7963cd2e8 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 27 Mar 2023 06:25:04 -0400 Subject: [PATCH 0815/1058] Bump zwave-js-server-python to 0.47.0 (#90212) --- .../components/zwave_js/diagnostics.py | 4 ++-- .../components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 1 + tests/components/zwave_js/test_diagnostics.py | 11 +--------- tests/components/zwave_js/test_trigger.py | 20 +++++++++++++------ 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 50130fc26327..acb87a239ae3 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -117,7 +117,8 @@ async def async_get_config_entry_diagnostics( handshake_msgs = msgs[:-1] network_state = msgs[-1] network_state["result"]["state"]["nodes"] = [ - redact_node_state(node) for node in network_state["result"]["state"]["nodes"] + redact_node_state(async_redact_data(node, KEYS_TO_REDACT)) + for node in network_state["result"]["state"]["nodes"] ] return {"messages": [*handshake_msgs, network_state]} @@ -136,7 +137,6 @@ async def async_get_device_diagnostics( entities = get_device_entities(hass, node, device) assert client.version node_state = redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)) - node_state["statistics"] = node.statistics.data return { "versionInfo": { "driverVersion": client.version.driver_version, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a21f7a6f30b5..0ad934103d6a 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.46.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 134bc6f45969..cf3fbc10f4d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zigpy==0.53.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.46.0 +zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me zwave_me_ws==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 977c2896c0af..f1a499c45dd0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1950,7 +1950,7 @@ zigpy-znp==0.9.3 zigpy==0.53.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.46.0 +zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me zwave_me_ws==0.3.1 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 43489be4ccf7..f8a7a68f1399 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3201,6 +3201,7 @@ async def test_subscribe_log_updates( "multiline": False, "timestamp": "time", "label": "label", + "context": {"source": "config"}, }, ) client.driver.receive_event(event) diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index e3c144c4ac6b..773b799cd6f2 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -92,16 +92,7 @@ async def test_device_diagnostics( assert len(diagnostics_data["entities"]) == len( list(async_discover_node_values(multisensor_6, device, {device.id: set()})) ) - assert diagnostics_data["state"] == { - **multisensor_6.data, - "statistics": { - "commandsDroppedRX": 0, - "commandsDroppedTX": 0, - "commandsRX": 0, - "commandsTX": 0, - "timeoutResponse": 0, - }, - } + assert diagnostics_data["state"] == multisensor_6.data async def test_device_diagnostics_error(hass: HomeAssistant, integration) -> None: diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 9ba008066740..cbf68a55f5a3 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -601,7 +601,8 @@ async def test_zwave_js_event( }, ) - # Test that `node no event data filter` is triggered and `node event data filter` is not + # Test that `node no event data filter` is triggered and `node event data + # filter` is not event = Event( type="interview stage completed", data={ @@ -649,7 +650,8 @@ async def test_zwave_js_event( clear_events() - # Test that `controller no event data filter` is triggered and `controller event data filter` is not + # Test that `controller no event data filter` is triggered and `controller event + # data filter` is not event = Event( type="inclusion started", data={ @@ -672,7 +674,8 @@ async def test_zwave_js_event( clear_events() - # Test that both `controller no event data filter` and `controller event data filter` are triggered + # Test that both `controller no event data filter` and `controller event data + # filter`` are triggered event = Event( type="inclusion started", data={ @@ -695,7 +698,8 @@ async def test_zwave_js_event( clear_events() - # Test that `driver no event data filter` is triggered and `driver event data filter` is not + # Test that `driver no event data filter` is triggered and `driver event data + # filter` is not event = Event( type="logging", data={ @@ -711,6 +715,7 @@ async def test_zwave_js_event( "multiline": False, "timestamp": "time", "label": "label", + "context": {"source": "config"}, }, ) client.driver.receive_event(event) @@ -727,7 +732,8 @@ async def test_zwave_js_event( clear_events() - # Test that both `driver no event data filter` and `driver event data filter` are triggered + # Test that both `driver no event data filter` and `driver event data filter` + # are triggered event = Event( type="logging", data={ @@ -743,6 +749,7 @@ async def test_zwave_js_event( "multiline": False, "timestamp": "time", "label": "label", + "context": {"source": "config"}, }, ) client.driver.receive_event(event) @@ -862,7 +869,8 @@ async def test_zwave_js_event_bypass_dynamic_validation( }, ) - # Test that `node no event data filter` is triggered and `node event data filter` is not + # Test that `node no event data filter` is triggered and `node event data filter` + # is not event = Event( type="interview stage completed", data={ From 53de9dcdbc3c2a2119a84f72bd76f7e090c351d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 13:09:42 +0200 Subject: [PATCH 0816/1058] Fix pylint plugin for binary websocket (#90351) --- pylint/plugins/hass_enforce_type_hints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 58bfd3b69b13..0581569c2d68 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2323,7 +2323,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="async_process_audio_stream", - arg_types={1: "SpeechMetadata", 2: "StreamReader"}, + arg_types={1: "SpeechMetadata", 2: "AsyncIterable[bytes]"}, return_type="SpeechResult", ), ], From c193402ba72e84fcca851c26dc0a764a6c8efa1a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 13:53:14 +0200 Subject: [PATCH 0817/1058] Remove incorrect ignore in pylint plugin (#90024) * Remove incorrect ignore_missing_annotations * Allow tuple[int, int] in hs_color * Adjust notify targets * Always check for return type inheritance * Adjust tests * Revert "Always check for return type inheritance" This reverts commit 3528742adf98edc6481f2c954c032ace881e1d6e. * Revert "Allow tuple[int, int] in hs_color" This reverts commit d51c1731eff3d59b9e94e7a7e914933ceaf8e34f. * Revert "Adjust notify targets" This reverts commit 4cba77a7309dc89980e29d6d5b9107d9e55f7070. --- pylint/plugins/hass_enforce_type_hints.py | 4 +--- tests/pylint/test_enforce_type_hints.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 0581569c2d68..4fb471e2145e 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2974,9 +2974,7 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] if class_matches := _CLASS_MATCH.get(module_platform): self._class_matchers.extend(class_matches) - if not self.linter.config.ignore_missing_annotations and ( - property_matches := _INHERITANCE_MATCH.get(module_platform) - ): + if property_matches := _INHERITANCE_MATCH.get(module_platform): self._class_matchers.extend(property_matches) self._class_matchers.reverse() diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 9e8df452b61f..b80d8f01445d 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -574,7 +574,7 @@ def test_ignore_invalid_entity_properties( async def async_lock( self, **kwargs - ) -> bool: + ): pass """, "homeassistant.components.pylint_test.lock", From a91aef9d52bf64d15776ea8ce0f657e83f7b998e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Mar 2023 14:01:17 +0200 Subject: [PATCH 0818/1058] Rewrite tts tests (#90355) --- tests/components/tts/test_init.py | 442 ++++++++++++++++++------------ 1 file changed, 263 insertions(+), 179 deletions(-) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 8c21336b59fa..e7b3a818f63e 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,12 +1,11 @@ """The tests for the TTS component.""" from http import HTTPStatus -from unittest.mock import PropertyMock, patch +from typing import Any import pytest import voluptuous as vol from homeassistant.components import media_source, tts -from homeassistant.components.demo.tts import DemoProvider from homeassistant.components.media_player import ( ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, @@ -18,10 +17,17 @@ from homeassistant.components.media_player import ( from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component from homeassistant.util.network import normalize_url -from tests.common import assert_setup_component, async_mock_service +from tests.common import ( + MockModule, + assert_setup_component, + async_mock_service, + mock_integration, + mock_platform, +) from tests.typing import ClientSessionGenerator ORIG_WRITE_TAGS = tts.SpeechManager.write_tags @@ -36,10 +42,68 @@ async def get_media_source_url(hass, media_content_id): return resolved.url +SUPPORT_LANGUAGES = ["de", "en", "en_US"] + +DEFAULT_LANG = "en" + + +class MockProvider(tts.Provider): + """Test speech API provider.""" + + def __init__(self, lang: str) -> None: + """Initialize test provider.""" + self._lang = lang + self.name = "Test" + + @property + def default_language(self) -> str: + """Return the default language.""" + return self._lang + + @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, emotions.""" + return ["voice", "age"] + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return ("mp3", b"") + + +class MockTTS: + """A mock TTS platform.""" + + PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA.extend( + {vol.Optional(tts.CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} + ) + + def __init__(self, provider=None) -> None: + """Initialize.""" + if provider is None: + provider = MockProvider + self._provider = provider + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> tts.Provider: + """Set up a mock speech component.""" + return self._provider(config.get(tts.CONF_LANG, DEFAULT_LANG)) + + @pytest.fixture -def demo_provider(): - """Demo TTS provider.""" - return DemoProvider("en") +def test_provider(): + """Test TTS provider.""" + return MockProvider("en") @pytest.fixture(autouse=True) @@ -52,49 +116,52 @@ async def internal_url_mock(hass): @pytest.fixture -async def setup_tts(hass): +async def mock_tts(hass): """Mock TTS.""" - with patch("homeassistant.components.demo.async_setup", return_value=True): - assert await async_setup_component( - hass, tts.DOMAIN, {"tts": {"platform": "demo"}} - ) - await hass.async_block_till_done() + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS()) -async def test_setup_component_demo(hass: HomeAssistant, setup_tts) -> None: - """Set up the demo platform with defaults.""" - assert hass.services.has_service(tts.DOMAIN, "demo_say") +@pytest.fixture +async def setup_tts(hass, mock_tts): + """Mock TTS.""" + assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) + + +async def test_setup_component(hass: HomeAssistant, setup_tts) -> None: + """Set up a TTS platform with defaults.""" + assert hass.services.has_service(tts.DOMAIN, "test_say") assert hass.services.has_service(tts.DOMAIN, "clear_cache") - assert f"{tts.DOMAIN}.demo" in hass.config.components + assert f"{tts.DOMAIN}.test" in hass.config.components -async def test_setup_component_demo_no_access_cache_folder( - hass: HomeAssistant, mock_init_cache_dir +async def test_setup_component_no_access_cache_folder( + hass: HomeAssistant, mock_init_cache_dir, mock_tts ) -> None: - """Set up the demo platform with defaults.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform with defaults.""" + config = {tts.DOMAIN: {"platform": "test"}} mock_init_cache_dir.side_effect = OSError(2, "No access") assert not await async_setup_component(hass, tts.DOMAIN, config) - assert not hass.services.has_service(tts.DOMAIN, "demo_say") + assert not hass.services.has_service(tts.DOMAIN, "test_say") assert not hass.services.has_service(tts.DOMAIN, "clear_cache") async def test_setup_component_and_test_service( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -107,28 +174,28 @@ async def test_setup_component_and_test_service( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_config_language( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} + config = {tts.DOMAIN: {"platform": "test", "language": "de"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -139,31 +206,28 @@ async def test_setup_component_and_test_service_with_config_language( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_config_language_special( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service with extend language.""" - import homeassistant.components.demo.tts as demo_tts - - demo_tts.SUPPORT_LANGUAGES.append("en_US") + """Set up a TTS platform and call service with extend language.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "en_US"}} + config = {tts.DOMAIN: {"platform": "test", "language": "en_US"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -174,38 +238,38 @@ async def test_setup_component_and_test_service_with_config_language_special( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_wrong_conf_language( - hass: HomeAssistant, + hass: HomeAssistant, mock_tts ) -> None: - """Set up the demo platform and call service with wrong config.""" - config = {tts.DOMAIN: {"platform": "demo", "language": "ru"}} + """Set up a TTS platform and call service with wrong config.""" + config = {tts.DOMAIN: {"platform": "test", "language": "ru"}} with assert_setup_component(0, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) async def test_setup_component_and_test_service_with_service_language( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -217,21 +281,21 @@ async def test_setup_component_and_test_service_with_service_language( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3" ).is_file() async def test_setup_component_test_service_with_wrong_service_language( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service.""" + """Set up a TTS platform and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -239,7 +303,7 @@ async def test_setup_component_test_service_with_wrong_service_language( with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -249,24 +313,24 @@ async def test_setup_component_test_service_with_wrong_service_language( ) assert len(calls) == 0 assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_service_options( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service with options.""" + """Set up a TTS platform and call service with options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -281,32 +345,37 @@ async def test_setup_component_and_test_service_with_service_options( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_with_service_options_def( hass: HomeAssistant, empty_cache_dir ) -> None: - """Set up the demo platform and call service with default options.""" + """Set up a TTS platform and call service with default options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} - with assert_setup_component(1, tts.DOMAIN), patch( - "homeassistant.components.demo.tts.DemoProvider.default_options", - new_callable=PropertyMock(return_value={"voice": "alex"}), - ): + class MockProviderWithDefaults(MockProvider): + @property + def default_options(self): + return {"voice": "alex"} + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) + + with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -320,22 +389,22 @@ async def test_setup_component_and_test_with_service_options_def( assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ) await hass.async_block_till_done() assert ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_service_options_wrong( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service with wrong options.""" + """Set up a TTS platform and call service with wrong options.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -343,7 +412,7 @@ async def test_setup_component_and_test_service_with_service_options_wrong( with pytest.raises(HomeAssistantError): await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -358,24 +427,24 @@ async def test_setup_component_and_test_service_with_service_options_wrong( await hass.async_block_till_done() assert not ( empty_cache_dir - / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_base_url_set( - hass: HomeAssistant, + hass: HomeAssistant, mock_tts ) -> None: - """Set up the demo platform with ``base_url`` set and call service.""" + """Set up a TTS platform with ``base_url`` set and call service.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "base_url": "http://fnord"}} + config = {tts.DOMAIN: {"platform": "test", "base_url": "http://fnord"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -388,24 +457,24 @@ async def test_setup_component_and_test_service_with_base_url_set( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) == "http://fnord" "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - "_en_-_demo.mp3" + "_en_-_test.mp3" ) async def test_setup_component_and_test_service_clear_cache( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up the demo platform and call service clear cache.""" + """Set up a TTS platform and call service clear cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -417,7 +486,7 @@ async def test_setup_component_and_test_service_clear_cache( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) await hass.async_block_till_done() assert ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() await hass.services.async_call( @@ -425,17 +494,17 @@ async def test_setup_component_and_test_service_clear_cache( ) assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_and_test_service_with_receive_voice( - hass: HomeAssistant, demo_provider, hass_client: ClientSessionGenerator + hass: HomeAssistant, test_provider, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and call service and receive voice.""" + """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) @@ -444,7 +513,7 @@ async def test_setup_component_and_test_service_with_receive_voice( await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: message, @@ -456,39 +525,39 @@ async def test_setup_component_and_test_service_with_receive_voice( url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) client = await hass_client() req = await client.get(url) - _, demo_data = demo_provider.get_tts_audio("bla", "en") - demo_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - demo_data, - demo_provider, + _, tts_data = test_provider.get_tts_audio("bla", "en") + tts_data = tts.SpeechManager.write_tags( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", + tts_data, + test_provider, message, "en", None, ) assert req.status == HTTPStatus.OK - assert await req.read() == demo_data + assert await req.read() == tts_data extension, data = await tts.async_get_media_source_audio( hass, calls[0].data[ATTR_MEDIA_CONTENT_ID] ) assert extension == "mp3" - assert demo_data == data + assert tts_data == data async def test_setup_component_and_test_service_with_receive_voice_german( - hass: HomeAssistant, demo_provider, hass_client: ClientSessionGenerator + hass: HomeAssistant, test_provider, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and call service and receive voice.""" + """Set up a TTS platform and call service and receive voice.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} + config = {tts.DOMAIN: {"platform": "test", "language": "de"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -499,67 +568,67 @@ async def test_setup_component_and_test_service_with_receive_voice_german( url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) client = await hass_client() req = await client.get(url) - _, demo_data = demo_provider.get_tts_audio("bla", "de") - demo_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3", - demo_data, - demo_provider, + _, tts_data = test_provider.get_tts_audio("bla", "de") + tts_data = tts.SpeechManager.write_tags( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_test.mp3", + tts_data, + test_provider, "There is someone at the door.", "de", None, ) assert req.status == HTTPStatus.OK - assert await req.read() == demo_data + assert await req.read() == tts_data async def test_setup_component_and_web_view_wrong_file( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive wrong file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive wrong file from web.""" + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() - url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND async def test_setup_component_and_web_view_wrong_filename( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive wrong filename from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive wrong filename from web.""" + config = {tts.DOMAIN: {"platform": "test"}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() - url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_demo.mp3" + url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.NOT_FOUND async def test_setup_component_test_without_cache( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up demo platform without cache.""" + """Set up a TTS platform without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "cache": False}} + config = {tts.DOMAIN: {"platform": "test", "cache": False}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -569,24 +638,24 @@ async def test_setup_component_test_without_cache( assert len(calls) == 1 await hass.async_block_till_done() assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_test_with_cache_call_service_without_cache( - hass: HomeAssistant, empty_cache_dir + hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: - """Set up demo platform with cache and call service without cache.""" + """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "test", "cache": True}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) await hass.services.async_call( tts.DOMAIN, - "demo_say", + "test_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", @@ -597,116 +666,131 @@ async def test_setup_component_test_with_cache_call_service_without_cache( assert len(calls) == 1 await hass.async_block_till_done() assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ).is_file() async def test_setup_component_test_with_cache_dir( - hass: HomeAssistant, empty_cache_dir, demo_provider + hass: HomeAssistant, empty_cache_dir, test_provider ) -> None: - """Set up demo platform with cache and call service without cache.""" + """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - _, demo_data = demo_provider.get_tts_audio("bla", "en") + _, tts_data = test_provider.get_tts_audio("bla", "en") cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) with open(cache_file, "wb") as voice_file: - voice_file.write(demo_data) + voice_file.write(tts_data) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "test", "cache": True}} + + class MockProviderBoom(MockProvider): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + # This should not be called, data should be fetched from cache + raise Exception("Boom!") + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderBoom)) with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=(None, None), - ): - await hass.services.async_call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - blocking=True, - ) + await hass.services.async_call( + tts.DOMAIN, + "test_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) assert len(calls) == 1 assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + == "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) -> None: - """Set up demo platform with wrong get_tts_audio.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform with wrong get_tts_audio.""" + config = {tts.DOMAIN: {"platform": "test"}} - with assert_setup_component(1, tts.DOMAIN), patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=(None, None), - ): + class MockProviderEmpty(MockProvider): + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return (None, None) + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderEmpty)) + + with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) async def test_setup_component_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, - demo_provider, + test_provider, empty_cache_dir, hass_client: ClientSessionGenerator, + mock_tts, ) -> None: """Set up component and load cache and get without mem cache.""" - _, demo_data = demo_provider.get_tts_audio("bla", "en") + _, tts_data = test_provider.get_tts_audio("bla", "en") cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) with open(cache_file, "wb") as voice_file: - voice_file.write(demo_data) + voice_file.write(tts_data) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "test", "cache": True}} with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() - url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" req = await client.get(url) assert req.status == HTTPStatus.OK - assert await req.read() == demo_data + assert await req.read() == tts_data async def test_setup_component_and_web_get_url( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive file from web.""" + config = {tts.DOMAIN: {"platform": "test"}} await async_setup_component(hass, tts.DOMAIN, config) client = await hass_client() url = "/api/tts_get_url" - data = {"platform": "demo", "message": "There is someone at the door."} + data = {"platform": "test", "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_en_-_demo.mp3", - "path": "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", + "url": "http://example.local:8123/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", + "path": "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3", } async def test_setup_component_and_web_get_url_bad_config( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts ) -> None: - """Set up the demo platform and receive wrong file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + """Set up a TTS platform and receive wrong file from web.""" + config = {tts.DOMAIN: {"platform": "test"}} await async_setup_component(hass, tts.DOMAIN, config) @@ -719,25 +803,25 @@ async def test_setup_component_and_web_get_url_bad_config( assert req.status == HTTPStatus.BAD_REQUEST -async def test_tags_with_wave(hass: HomeAssistant, demo_provider) -> None: - """Set up the demo platform and call service and receive voice.""" +async def test_tags_with_wave(hass: HomeAssistant, test_provider) -> None: + """Set up a TTS platform and call service and receive voice.""" # below data represents an empty wav file - demo_data = bytes.fromhex( + tts_data = bytes.fromhex( "52 49 46 46 24 00 00 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 02 00" + "22 56 00 00 88 58 01 00 04 00 10 00 64 61 74 61 00 00 00 00" ) tagged_data = ORIG_WRITE_TAGS( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.wav", - demo_data, - demo_provider, + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.wav", + tts_data, + test_provider, "AI person is in front of your door.", "en", None, ) - assert tagged_data != demo_data + assert tagged_data != tts_data @pytest.mark.parametrize( @@ -781,10 +865,10 @@ def test_invalid_base_url(value) -> None: @pytest.mark.parametrize( ("engine", "language", "options", "cache", "result_engine", "result_query"), ( - (None, None, None, None, "demo", ""), - (None, "de", None, None, "demo", "language=de"), - (None, "de", {"voice": "henk"}, None, "demo", "language=de&voice=henk"), - (None, "de", None, True, "demo", "cache=true&language=de"), + (None, None, None, None, "test", ""), + (None, "de", None, None, "test", "language=de"), + (None, "de", {"voice": "henk"}, None, "test", "language=de&voice=henk"), + (None, "de", None, True, "test", "cache=true&language=de"), ), ) async def test_generate_media_source_id( From a32c78238eccee99ac9558b08be2c8e3c1a145ca Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Mon, 27 Mar 2023 23:06:30 +1100 Subject: [PATCH 0819/1058] Bump lifx dependencies (#90345) --- homeassistant/components/lifx/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 0019f68ab9db..65f4e7ecefa7 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -42,7 +42,7 @@ "quality_scale": "platinum", "requirements": [ "aiolifx==0.8.9", - "aiolifx_effects==0.3.1", - "aiolifx_themes==0.4.0" + "aiolifx_effects==0.3.2", + "aiolifx_themes==0.4.5" ] } diff --git a/requirements_all.txt b/requirements_all.txt index cf3fbc10f4d8..91238f06f229 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -196,10 +196,10 @@ aiokef==0.2.16 aiolifx==0.8.9 # homeassistant.components.lifx -aiolifx_effects==0.3.1 +aiolifx_effects==0.3.2 # homeassistant.components.lifx -aiolifx_themes==0.4.0 +aiolifx_themes==0.4.5 # homeassistant.components.livisi aiolivisi==0.0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1a499c45dd0..f16cbf7c37cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -180,10 +180,10 @@ aiokafka==0.7.2 aiolifx==0.8.9 # homeassistant.components.lifx -aiolifx_effects==0.3.1 +aiolifx_effects==0.3.2 # homeassistant.components.lifx -aiolifx_themes==0.4.0 +aiolifx_themes==0.4.5 # homeassistant.components.livisi aiolivisi==0.0.19 From 2ce3c014ff8a1663a74554d4f9e553ea69041dbf Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 27 Mar 2023 10:27:55 -0300 Subject: [PATCH 0820/1058] Move Proxmox VE constants (#90357) * Move constants to const.py * Update homeassistant/components/proxmoxve/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/proxmoxve/__init__.py | 38 +++++++++---------- homeassistant/components/proxmoxve/const.py | 22 +++++++++++ 2 files changed, 40 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/proxmoxve/const.py diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 8e8842abf477..f8e350f2b157 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import timedelta -import logging from proxmoxer import ProxmoxAPI from proxmoxer.backends.https import AuthenticationError @@ -28,26 +27,25 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) +from .const import ( + _LOGGER, + CONF_CONTAINERS, + CONF_NODE, + CONF_NODES, + CONF_REALM, + CONF_VMS, + COORDINATORS, + DEFAULT_PORT, + DEFAULT_REALM, + DEFAULT_VERIFY_SSL, + DOMAIN, + PROXMOX_CLIENTS, + TYPE_CONTAINER, + TYPE_VM, + UPDATE_INTERVAL, +) + PLATFORMS = [Platform.BINARY_SENSOR] -DOMAIN = "proxmoxve" -PROXMOX_CLIENTS = "proxmox_clients" -CONF_REALM = "realm" -CONF_NODE = "node" -CONF_NODES = "nodes" -CONF_VMS = "vms" -CONF_CONTAINERS = "containers" - -COORDINATORS = "coordinators" -API_DATA = "api_data" - -DEFAULT_PORT = 8006 -DEFAULT_REALM = "pam" -DEFAULT_VERIFY_SSL = True -TYPE_VM = 0 -TYPE_CONTAINER = 1 -UPDATE_INTERVAL = 60 - -_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py new file mode 100644 index 000000000000..6477c081463a --- /dev/null +++ b/homeassistant/components/proxmoxve/const.py @@ -0,0 +1,22 @@ +"""Constants for ProxmoxVE.""" + +import logging + +DOMAIN = "proxmoxve" +PROXMOX_CLIENTS = "proxmox_clients" +CONF_REALM = "realm" +CONF_NODE = "node" +CONF_NODES = "nodes" +CONF_VMS = "vms" +CONF_CONTAINERS = "containers" + +COORDINATORS = "coordinators" + +DEFAULT_PORT = 8006 +DEFAULT_REALM = "pam" +DEFAULT_VERIFY_SSL = True +TYPE_VM = 0 +TYPE_CONTAINER = 1 +UPDATE_INTERVAL = 60 + +_LOGGER = logging.getLogger(__package__) From fd3280260d79ba5a774292624321639620531869 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 16:39:51 +0200 Subject: [PATCH 0821/1058] Remove unreachable continue statement in imap push coordinator (#90361) --- homeassistant/components/imap/coordinator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index e9bbb623013e..69f291df6eb8 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -199,7 +199,6 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): self.async_set_update_error(UpdateFailed("Lost connection")) await self._cleanup() await asyncio.sleep(BACKOFF_TIME) - continue async def shutdown(self, *_) -> None: """Close resources.""" From 89f89cab2ca8778de717bb2564fcb46830ce7101 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 16:55:10 +0200 Subject: [PATCH 0822/1058] Use entity name translations in Verisure (#90362) --- .../components/verisure/binary_sensor.py | 2 +- homeassistant/components/verisure/sensor.py | 4 ++-- homeassistant/components/verisure/strings.json | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index a960107c7140..68d549eaa5d1 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -98,7 +98,7 @@ class VerisureEthernetStatus( _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_has_entity_name = True - _attr_name = "Ethernet status" + _attr_translation_key = "ethernet" @property def unique_id(self) -> str: diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 0b519b472694..7c9639b65423 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -47,7 +47,7 @@ class VerisureThermometer( _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_has_entity_name = True - _attr_name = "Temperature" + _attr_translation_key = "temperature" _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_state_class = SensorStateClass.MEASUREMENT @@ -99,7 +99,7 @@ class VerisureHygrometer( _attr_device_class = SensorDeviceClass.HUMIDITY _attr_has_entity_name = True - _attr_name = "Humidity" + _attr_translation_key = "humidity" _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index c8326d737569..17feb4a7fe9e 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -56,5 +56,20 @@ "error": { "code_format_mismatch": "The default PIN code does not match the required number of digits" } + }, + "entity": { + "binary_sensor": { + "ethernet": { + "name": "Ethernet status" + } + }, + "sensor": { + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + } + } } } From f4fda55405764446c571701a23860ce0fe0b1254 Mon Sep 17 00:00:00 2001 From: javicalle <31999997+javicalle@users.noreply.github.com> Date: Mon, 27 Mar 2023 17:57:40 +0200 Subject: [PATCH 0823/1058] Fix `quirk_class_validator` in ZHA unit tests (#90140) * Fix `quirk_class_validator` Fix the `quirk_class_validator` for quirks with more than 1 module level * fix black * Shorten `quirk_cls` in `clss` Co-authored-by: TheJulianJES * Update comment --------- Co-authored-by: TheJulianJES --- tests/components/zha/test_registries.py | 26 +++++++------------------ 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 6a6bf758cebc..80b1f10f5613 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -1,4 +1,5 @@ """Test ZHA registries.""" +import importlib import inspect from unittest import mock @@ -440,24 +441,11 @@ def test_quirk_classes() -> None: def find_quirk_class(base_obj, quirk_mod, quirk_cls): """Find a specific quirk class.""" - mods = dict(inspect.getmembers(base_obj, inspect.ismodule)) - # Check if we have found the right module - if quirk_mod in mods: - # If so, look for the class - clss = dict(inspect.getmembers(mods[quirk_mod], inspect.isclass)) - if quirk_cls in clss: - # Quirk class found - return True - - else: - # Recurse into other modules - for mod in mods: - if not mods[mod].__name__.startswith("zhaquirks."): - continue - if find_quirk_class(mods[mod], quirk_mod, quirk_cls): - return True - return False + module = importlib.import_module(quirk_mod) + clss = dict(inspect.getmembers(module, inspect.isclass)) + # Check quirk_cls in module classes + return quirk_cls in clss def quirk_class_validator(value): """Validate quirk classes during self test.""" @@ -471,9 +459,9 @@ def test_quirk_classes() -> None: quirk_class_validator(v) return - quirk_tok = value.split(".") + quirk_tok = value.rsplit(".", 1) if len(quirk_tok) != 2: - # quirk_class is always __module__.__class__ + # quirk_class is at least __module__.__class__ raise ValueError(f"Invalid quirk class : '{value}'") if not find_quirk_class(zhaquirks, quirk_tok[0], quirk_tok[1]): From b033232b06e14759bd886204e2efd3347a8384be Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 19:49:40 +0200 Subject: [PATCH 0824/1058] Filter out ASCII tab or newline from input URLs (#90348) --- .../components/http/security_filter.py | 18 +++++++ tests/components/http/test_security_filter.py | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/homeassistant/components/http/security_filter.py b/homeassistant/components/http/security_filter.py index a9b32bd7f4c8..e8e3aa4699c1 100644 --- a/homeassistant/components/http/security_filter.py +++ b/homeassistant/components/http/security_filter.py @@ -35,6 +35,9 @@ FILTERS: Final = re.compile( ) # fmt: on +# Unsafe bytes to be removed per WHATWG spec +UNSAFE_URL_BYTES = ["\t", "\r", "\n"] + @callback def setup_security_filter(app: Application) -> None: @@ -51,6 +54,21 @@ def setup_security_filter(app: Application) -> None: request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """Process request and block commonly known exploit attempts.""" + for unsafe_byte in UNSAFE_URL_BYTES: + if unsafe_byte in request.path: + _LOGGER.warning( + "Filtered a request with an unsafe byte in path: %s", + request.raw_path, + ) + raise HTTPBadRequest + + if unsafe_byte in request.query_string: + _LOGGER.warning( + "Filtered a request with unsafe byte 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 diff --git a/tests/components/http/test_security_filter.py b/tests/components/http/test_security_filter.py index 1c139a591611..5469b7ebfa72 100644 --- a/tests/components/http/test_security_filter.py +++ b/tests/components/http/test_security_filter.py @@ -107,3 +107,54 @@ async def test_bad_requests( if fail_on_query_string: message = "Filtered a request with a potential harmful query string:" assert message in caplog.text + + +@pytest.mark.parametrize( + ("request_path", "request_params", "fail_on_query_string"), + [ + ("/some\thing", {}, False), + ("/new\nline/cinema", {}, False), + ("/return\r/to/sender", {}, False), + ("/", {"some": "\thing"}, True), + ("/", {"\newline": "cinema"}, True), + ("/", {"return": "t\rue"}, True), + ], +) +async def test_bad_requests_with_unsafe_bytes( + request_path, + request_params, + fail_on_query_string, + aiohttp_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, + loop, +) -> None: + """Test request with unsafe bytes in their URLs.""" + app = web.Application() + app.router.add_get("/{all:.*}", mock_handler) + + setup_security_filter(app) + + mock_api_client = await aiohttp_client(app) + + # Manual params handling + if request_params: + raw_params = "&".join(f"{val}={key}" for val, key in request_params.items()) + man_params = f"?{raw_params}" + else: + man_params = "" + + http = urllib3.PoolManager() + resp = await loop.run_in_executor( + None, + http.request, + "GET", + f"http://{mock_api_client.host}:{mock_api_client.port}{request_path}{man_params}", + request_params, + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + + message = "Filtered a request with an unsafe byte in path:" + if fail_on_query_string: + message = "Filtered a request with unsafe byte query string:" + assert message in caplog.text From d59e2b13496e51f30ed05f3c201423fc09e88a95 Mon Sep 17 00:00:00 2001 From: Trevor Bernard Date: Mon, 27 Mar 2023 12:57:56 -0500 Subject: [PATCH 0825/1058] Add "stream" to default_config (#90153) * add stream to manifest * Update __init__.py remove av check from init * Update homeassistant/components/default_config/__init__.py * Update requirements --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- homeassistant/components/default_config/__init__.py | 11 +---------- homeassistant/components/default_config/manifest.json | 1 + homeassistant/package_constraints.txt | 2 ++ 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index 574d97c6d29f..d91d06949e69 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -1,10 +1,4 @@ """Component providing default configuration for new users.""" - -try: - import av -except ImportError: - av = None - from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType @@ -18,7 +12,4 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not is_hassio(hass): await async_setup_component(hass, "backup", config) - if av is None: - return True - - return await async_setup_component(hass, "stream", config) + return True diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index d4faaddaa5d6..a1add4759489 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -33,6 +33,7 @@ "schedule", "script", "ssdp", + "stream", "sun", "system_health", "tag", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd568947cf43..ae190115b2aa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,6 @@ PyJWT==2.6.0 PyNaCl==1.5.0 +PyTurboJPEG==1.6.7 aiodiscover==1.4.14 aiohttp==3.8.4 aiohttp_cors==0.7.0 @@ -20,6 +21,7 @@ ciso8601==2.3.0 cryptography==40.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 +ha-av==10.0.0 hass-nabucasa==0.62.0 hassil==1.0.6 home-assistant-bluetooth==1.9.3 From a4051121421bab0ec3784e7019882674a5b0f879 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 19:59:57 +0200 Subject: [PATCH 0826/1058] Add state translations for Script entities (#90354) --- homeassistant/components/script/strings.json | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index c78e4265cbd7..b9624f16a313 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -6,6 +6,29 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "current": { + "name": "[%key:component::automation::entity_component::_::state_attributes::current::name%]" + }, + "last_action": { + "name": "Last action" + }, + "last_triggered": { + "name": "[%key:component::automation::entity_component::_::state_attributes::last_triggered::name%]" + }, + "max": { + "name": "Max running scripts" + }, + "mode": { + "name": "[%key:component::automation::entity_component::_::state_attributes::mode::name%]", + "state": { + "parallel": "[%key:component::automation::entity_component::_::state_attributes::mode::state::parallel%]", + "queued": "[%key:component::automation::entity_component::_::state_attributes::mode::state::queued%]", + "restart": "[%key:component::automation::entity_component::_::state_attributes::mode::state::restart%]", + "single": "[%key:component::automation::entity_component::_::state_attributes::mode::state::single%]" + } + } } } } From f84651b14e1d51999e5919f745a553a94a87eada Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Mar 2023 20:00:54 +0200 Subject: [PATCH 0827/1058] Improve tts test coverage (#90370) --- tests/components/tts/test_init.py | 64 +++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index e7b3a818f63e..251ed9b30c0f 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -14,6 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.components.media_source import Unresolvable from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -398,6 +399,54 @@ async def test_setup_component_and_test_with_service_options_def( ).is_file() +async def test_setup_component_and_test_with_service_options_def_2( + hass: HomeAssistant, empty_cache_dir +) -> None: + """Set up a TTS platform and call service with default options. + + This tests merging default and user provided options. + """ + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = {tts.DOMAIN: {"platform": "test"}} + + class MockProviderWithDefaults(MockProvider): + @property + def default_options(self): + return {"voice": "alex"} + + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS(MockProviderWithDefaults)) + + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "test_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de", + tts.ATTR_OPTIONS: {"age": 5}, + }, + blocking=True, + ) + opt_hash = tts._hash_options({"voice": "alex", "age": 5}) + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC + assert ( + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == f"/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" + ) + await hass.async_block_till_done() + assert ( + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_test.mp3" + ).is_file() + + async def test_setup_component_and_test_service_with_service_options_wrong( hass: HomeAssistant, empty_cache_dir, mock_tts ) -> None: @@ -718,6 +767,8 @@ async def test_setup_component_test_with_cache_dir( async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) -> None: """Set up a TTS platform with wrong get_tts_audio.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + config = {tts.DOMAIN: {"platform": "test"}} class MockProviderEmpty(MockProvider): @@ -733,6 +784,19 @@ async def test_setup_component_test_with_error_on_get_tts(hass: HomeAssistant) - with assert_setup_component(1, tts.DOMAIN): assert await async_setup_component(hass, tts.DOMAIN, config) + await hass.services.async_call( + tts.DOMAIN, + "test_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + with pytest.raises(Unresolvable): + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + async def test_setup_component_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, From 18933df95c0619db7500edab1c2162b80f70638e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 27 Mar 2023 20:06:59 +0200 Subject: [PATCH 0828/1058] Clean dead code from matter (#90369) --- homeassistant/components/matter/models.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index 2575b16e8b16..eaa9ccf9a099 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -2,8 +2,8 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import asdict, dataclass -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from typing import Any from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -13,19 +13,6 @@ from matter_server.client.models.node import MatterEndpoint from homeassistant.const import Platform from homeassistant.helpers.entity import EntityDescription -if TYPE_CHECKING: - from _typeshed import DataclassInstance - - -class DataclassMustHaveAtLeastOne: - """A dataclass that must have at least one input parameter that is not None.""" - - def __post_init__(self: DataclassInstance) -> None: - """Post dataclass initialization.""" - if all(val is None for val in asdict(self).values()): - raise ValueError("At least one input parameter must not be None") - - SensorValueTypes = type[ clusters.uint | int | clusters.Nullable | clusters.float32 | float ] From 1937d803c5f9fba91b4f9c810fa376d1dd8a13ff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Mar 2023 20:08:20 +0200 Subject: [PATCH 0829/1058] Add RestoreEntity pylint checks to all platforms (#90020) --- pylint/plugins/hass_enforce_type_hints.py | 84 +++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 4fb471e2145e..a84d578cf525 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -710,6 +710,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="AlarmControlPanelEntity", matches=[ @@ -793,6 +797,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="BinarySensorEntity", matches=[ @@ -836,6 +844,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="CalendarEntity", matches=[ @@ -860,6 +872,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="Camera", matches=[ @@ -974,6 +990,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ClimateEntity", matches=[ @@ -1147,6 +1167,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="CoverEntity", matches=[ @@ -1246,6 +1270,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="BaseTrackerEntity", matches=[ @@ -1319,6 +1347,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -1400,6 +1432,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="GeolocationEvent", matches=[ @@ -1427,6 +1463,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ImageProcessingEntity", matches=[ @@ -1470,6 +1510,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -1525,6 +1569,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -1624,6 +1672,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="LockEntity", matches=[ @@ -1714,6 +1766,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="MediaPlayerEntity", matches=[ @@ -2118,6 +2174,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2185,6 +2245,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="SelectEntity", matches=[ @@ -2275,6 +2339,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2334,6 +2402,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2445,6 +2517,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -2580,6 +2656,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="WaterHeaterEntity", matches=[ @@ -2661,6 +2741,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { base_class="Entity", matches=_ENTITY_MATCH, ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="WeatherEntity", matches=[ From 506a916a136249898e95f5e6b989d9febf9c4c06 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Mar 2023 20:37:31 +0200 Subject: [PATCH 0830/1058] Add reauth flow to dormakaba dkey (#90225) --- .../components/dormakaba_dkey/__init__.py | 6 +- .../components/dormakaba_dkey/config_flow.py | 50 +++++++++++++-- .../components/dormakaba_dkey/strings.json | 5 ++ .../dormakaba_dkey/test_config_flow.py | 63 +++++++++++++++++++ 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/__init__.py b/homeassistant/components/dormakaba_dkey/__init__.py index 2f57d9802b90..4903e46b8dc8 100644 --- a/homeassistant/components/dormakaba_dkey/__init__.py +++ b/homeassistant/components/dormakaba_dkey/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta import logging from py_dormakaba_dkey import DKEYLock -from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS +from py_dormakaba_dkey.errors import DKEY_EXCEPTIONS, NotAssociated from py_dormakaba_dkey.models import AssociationData from homeassistant.components import bluetooth @@ -13,7 +13,7 @@ from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackM from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_ASSOCIATION_DATA, DOMAIN, UPDATE_SECONDS @@ -60,6 +60,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await lock.update() await lock.disconnect() + except NotAssociated as ex: + raise ConfigEntryAuthFailed("Not associated") from ex except DKEY_EXCEPTIONS as ex: raise UpdateFailed(str(ex)) from ex diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py index 3da1fd841fd4..f03861d015ee 100644 --- a/homeassistant/components/dormakaba_dkey/config_flow.py +++ b/homeassistant/components/dormakaba_dkey/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Dormakaba dKey integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -12,6 +13,7 @@ from homeassistant import config_entries from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_discovered_service_info, + async_last_service_info, ) from homeassistant.const import CONF_ADDRESS from homeassistant.data_entry_flow import FlowResult @@ -32,12 +34,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + def __init__(self) -> None: """Initialize the config flow.""" self._lock: DKEYLock | None = None # Populated by user step self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} - # Populated by bluetooth and user steps + # Populated by bluetooth, reauth_confirm and user steps self._discovery_info: BluetoothServiceInfoBleak | None = None async def async_step_user( @@ -113,6 +117,36 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_associate() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauthorization request.""" + 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: + """Handle reauthorization flow.""" + errors = {} + reauth_entry = self._reauth_entry + assert reauth_entry is not None + + if user_input is not None: + if ( + discovery_info := async_last_service_info( + self.hass, reauth_entry.data[CONF_ADDRESS], True + ) + ) is None: + errors = {"base": "no_longer_in_range"} + else: + self._discovery_info = discovery_info + return await self.async_step_associate() + + return self.async_show_form( + step_id="reauth_confirm", data_schema=vol.Schema({}), errors=errors + ) + async def async_step_associate( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -143,14 +177,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: + data = { + CONF_ADDRESS: self._discovery_info.device.address, + CONF_ASSOCIATION_DATA: association_data.to_json(), + } + if reauth_entry := self._reauth_entry: + self.hass.config_entries.async_update_entry(reauth_entry, data=data) + await self.hass.config_entries.async_reload(reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=lock.device_info.device_name or lock.device_info.device_id or lock.name, - data={ - CONF_ADDRESS: self._discovery_info.device.address, - CONF_ASSOCIATION_DATA: association_data.to_json(), - }, + data=data, ) return self.async_show_form( diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index d07deaca829e..efe9d3acb52c 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -11,6 +11,9 @@ "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, + "reauth_confirm": { + "description": "The activation code is no longer valid, a new unused activation code is needed.\n\n" + }, "associate": { "description": "Provide an unused activation code.\n\nTo create an activation code, create a new key in the dKey admin app, then choose to share the key and share an activation code.\n\nMake sure to close the dKey admin app before proceeding.", "data": { @@ -19,6 +22,7 @@ } }, "error": { + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "invalid_code": "Invalid activation code. An activation code consist of 8 characters, separated by a dash, e.g. GBZT-HXC0.", "wrong_code": "Wrong activation code. Note that an activation code can only be used once." }, @@ -26,6 +30,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/tests/components/dormakaba_dkey/test_config_flow.py b/tests/components/dormakaba_dkey/test_config_flow.py index 70c86524bed2..8c0156e221b7 100644 --- a/tests/components/dormakaba_dkey/test_config_flow.py +++ b/tests/components/dormakaba_dkey/test_config_flow.py @@ -296,3 +296,66 @@ async def test_bluetooth_step_cannot_associate(hass: HomeAssistant, exc, error) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "associate" assert result["errors"] == {"base": error} + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauthentication.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=DKEY_DISCOVERY_INFO.address, + data={"address": DKEY_DISCOVERY_INFO.address}, + ) + 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}, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_last_service_info", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "no_longer_in_range"} + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.async_last_service_info", + return_value=DKEY_DISCOVERY_INFO, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "associate" + assert result["errors"] is None + + with patch( + "homeassistant.components.dormakaba_dkey.config_flow.DKEYLock.associate", + return_value=AssociationData(b"1234", b"AABBCCDD"), + ) as mock_associate, patch( + "homeassistant.components.dormakaba_dkey.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"activation_code": "1234-1234"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == { + CONF_ADDRESS: DKEY_DISCOVERY_INFO.address, + "association_data": {"key_holder_id": "31323334", "secret": "4141424243434444"}, + } + mock_associate.assert_awaited_once_with("1234-1234") From 2ceb24e5d0569ad5ca0a9629da2f913221980623 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 20:49:49 +0200 Subject: [PATCH 0831/1058] Fail CI if codecov upload fails (#90363) --- .github/workflows/ci.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4e04059d154..e4fd319e7159 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1100,7 +1100,10 @@ jobs: if: needs.info.outputs.test_full_suite == 'true' uses: codecov/codecov-action@v3.1.1 with: + fail_ci_if_error: true flags: full-suite - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' uses: codecov/codecov-action@v3.1.1 + with: + fail_ci_if_error: true From 96698813efe605056bc2e6005b2529a51901660d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 Mar 2023 21:19:09 +0200 Subject: [PATCH 0832/1058] Cleanup command_line (#90268) * Cleanup command_line * Fix ipv6 resolver * Fix fix * Fix tests * Align states --- .../components/command_line/__init__.py | 61 ------------------ .../components/command_line/binary_sensor.py | 28 ++------- .../components/command_line/cover.py | 33 +++------- .../components/command_line/sensor.py | 38 +++--------- .../components/command_line/switch.py | 49 +++++++++------ .../components/command_line/utils.py | 62 +++++++++++++++++++ tests/components/command_line/test_cover.py | 6 +- tests/components/command_line/test_sensor.py | 2 +- tests/components/command_line/test_switch.py | 4 +- 9 files changed, 122 insertions(+), 161 deletions(-) create mode 100644 homeassistant/components/command_line/utils.py diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index c0713d0780b2..fe0640d3efa7 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1,62 +1 @@ """The command_line component.""" -from __future__ import annotations - -import logging -import subprocess - -_LOGGER = logging.getLogger(__name__) - - -def call_shell_with_timeout( - command: str, timeout: int, *, log_return_code: bool = True -) -> int: - """Run a shell command with a timeout. - - If log_return_code is set to False, it will not print an error if a non-zero - return code is returned. - """ - try: - _LOGGER.debug("Running command: %s", command) - subprocess.check_output( - command, - shell=True, # nosec # shell by design - timeout=timeout, - close_fds=False, # required for posix_spawn - ) - return 0 - except subprocess.CalledProcessError as proc_exception: - if log_return_code: - _LOGGER.error( - "Command failed (with return code %s): %s", - proc_exception.returncode, - command, - ) - return proc_exception.returncode - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) - return -1 - except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", command) - return -1 - - -def check_output_or_log(command: str, timeout: int) -> str | None: - """Run a shell command with a timeout and return the output.""" - try: - return_value = subprocess.check_output( - command, - shell=True, # nosec # shell by design - timeout=timeout, - close_fds=False, # required for posix_spawn - ) - return return_value.strip().decode("utf-8") - except subprocess.CalledProcessError as err: - _LOGGER.error( - "Command failed (with return code %s): %s", err.returncode, command - ) - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) - except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec command: %s", command) - - return None diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 2e1ddb7a9621..0c2edb8f1912 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -25,10 +25,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS @@ -65,10 +61,6 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - binary_sensor_config = vol.Schema( - TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(config) - name: str = config.get(CONF_NAME, DEFAULT_NAME) command: str = config[CONF_COMMAND] payload_off: str = config[CONF_PAYLOAD_OFF] @@ -84,8 +76,6 @@ async def async_setup_platform( async_add_entities( [ CommandBinarySensor( - hass, - binary_sensor_config, data, name, device_class, @@ -99,13 +89,11 @@ async def async_setup_platform( ) -class CommandBinarySensor(TemplateEntity, BinarySensorEntity): +class CommandBinarySensor(BinarySensorEntity): """Representation of a command line binary sensor.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, data: CommandSensorData, name: str, device_class: BinarySensorDeviceClass | None, @@ -115,19 +103,14 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity): unique_id: str | None, ) -> None: """Initialize the Command line binary sensor.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) self.data = data + self._attr_name = name self._attr_device_class = device_class self._attr_is_on = None self._payload_on = payload_on self._payload_off = payload_off self._value_template = value_template + self._attr_unique_id = unique_id async def async_update(self) -> None: """Get the latest data and updates the state.""" @@ -135,9 +118,10 @@ class CommandBinarySensor(TemplateEntity, BinarySensorEntity): value = self.data.value if self._value_template is not None: - value = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, value, False + value = self._value_template.async_render_with_possible_json_value( + value, None ) + self._attr_is_on = None if value == self._payload_on: self._attr_is_on = True elif value == self._payload_off: diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 53773ae4e91b..e477affc8541 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -22,14 +22,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, - TemplateEntity, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import call_shell_with_timeout, check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -69,14 +65,8 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass - cover_config = vol.Schema( - TEMPLATE_ENTITY_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(device_config) - covers.append( CommandCover( - hass, - cover_config, device_config.get(CONF_FRIENDLY_NAME, device_name), device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], @@ -95,13 +85,11 @@ async def async_setup_platform( async_add_entities(covers) -class CommandCover(TemplateEntity, CoverEntity): +class CommandCover(CoverEntity): """Representation a command line cover.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, name: str, command_open: str, command_close: str, @@ -112,13 +100,7 @@ class CommandCover(TemplateEntity, CoverEntity): unique_id: str | None, ) -> None: """Initialize the cover.""" - TemplateEntity.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) + self._attr_name = name self._state: int | None = None self._command_open = command_open self._command_close = command_close @@ -126,6 +108,7 @@ class CommandCover(TemplateEntity, CoverEntity): self._command_state = command_state self._value_template = value_template self._timeout = timeout + self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) def _move_cover(self, command: str) -> bool: @@ -170,10 +153,12 @@ class CommandCover(TemplateEntity, CoverEntity): if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._value_template: - payload = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, payload + payload = self._value_template.async_render_with_possible_json_value( + payload, None ) - self._state = int(payload) + self._state = None + if payload: + self._state = int(payload) def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 24224c12cac8..f459e4156619 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -22,7 +22,6 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError @@ -30,14 +29,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template -from homeassistant.helpers.template_entity import ( - TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateSensor, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -72,10 +67,6 @@ async def async_setup_platform( await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - sensor_config = vol.Schema( - TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.REMOVE_EXTRA - )(config) - name: str = config[CONF_NAME] command: str = config[CONF_COMMAND] unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) @@ -90,8 +81,6 @@ async def async_setup_platform( async_add_entities( [ CommandSensor( - hass, - sensor_config, data, name, unit, @@ -104,13 +93,11 @@ async def async_setup_platform( ) -class CommandSensor(TemplateSensor, SensorEntity): +class CommandSensor(SensorEntity): """Representation of a sensor that is using shell commands.""" def __init__( self, - hass: HomeAssistant, - config: ConfigType, data: CommandSensorData, name: str, unit_of_measurement: str | None, @@ -119,18 +106,14 @@ class CommandSensor(TemplateSensor, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - TemplateSensor.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) + self._attr_name = name self.data = data self._attr_extra_state_attributes = {} self._json_attributes = json_attributes self._attr_native_value = None self._value_template = value_template + self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_unique_id = unique_id async def async_update(self) -> None: """Get the latest data and updates the state.""" @@ -155,13 +138,12 @@ class CommandSensor(TemplateSensor, SensorEntity): else: _LOGGER.warning("Empty reply found when expecting JSON data") - if value is None: - value = STATE_UNKNOWN elif self._value_template is not None: - self._attr_native_value = await self.hass.async_add_executor_job( - self._value_template.render_with_possible_json_value, - value, - STATE_UNKNOWN, + self._attr_native_value = ( + self._value_template.async_render_with_possible_json_value( + value, + None, + ) ) else: self._attr_native_value = value diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7142f14e82d5..3c344891fbad 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -24,12 +24,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import setup_reload_service +from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import call_shell_with_timeout, check_output_or_log from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .utils import call_shell_with_timeout, check_output_or_log _LOGGER = logging.getLogger(__name__) @@ -51,15 +51,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Find and return switches controlled by shell commands.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) devices: dict[str, Any] = config.get(CONF_SWITCHES, {}) switches = [] @@ -92,7 +92,7 @@ def setup_platform( _LOGGER.error("No switches added") return - add_entities(switches) + async_add_entities(switches) class CommandSwitch(SwitchEntity): @@ -123,11 +123,16 @@ class CommandSwitch(SwitchEntity): self._attr_unique_id = unique_id self._attr_should_poll = bool(command_state) - def _switch(self, command: str) -> bool: + async def _switch(self, command: str) -> bool: """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = call_shell_with_timeout(command, self._timeout) == 0 + success = ( + await self.hass.async_add_executor_job( + call_shell_with_timeout, command, self._timeout + ) + == 0 + ) if not success: _LOGGER.error("Command failed: %s", command) @@ -160,26 +165,30 @@ class CommandSwitch(SwitchEntity): if TYPE_CHECKING: return None - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" if self._command_state: - payload = str(self._query_state()) + payload = str(await self.hass.async_add_executor_job(self._query_state)) if self._icon_template: - self._attr_icon = self._icon_template.render_with_possible_json_value( - payload + self._attr_icon = ( + self._icon_template.async_render_with_possible_json_value(payload) ) if self._value_template: - payload = self._value_template.render_with_possible_json_value(payload) - self._attr_is_on = payload.lower() == "true" + payload = self._value_template.async_render_with_possible_json_value( + payload, None + ) + self._attr_is_on = None + if payload: + self._attr_is_on = payload.lower() == "true" - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self._switch(self._command_on) and not self._command_state: + if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self._switch(self._command_off) and not self._command_state: + if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py new file mode 100644 index 000000000000..2d42732190ef --- /dev/null +++ b/homeassistant/components/command_line/utils.py @@ -0,0 +1,62 @@ +"""The command_line component utils.""" +from __future__ import annotations + +import logging +import subprocess + +_LOGGER = logging.getLogger(__name__) + + +def call_shell_with_timeout( + command: str, timeout: int, *, log_return_code: bool = True +) -> int: + """Run a shell command with a timeout. + + If log_return_code is set to False, it will not print an error if a non-zero + return code is returned. + """ + try: + _LOGGER.debug("Running command: %s", command) + subprocess.check_output( + command, + shell=True, # nosec # shell by design + timeout=timeout, + close_fds=False, # required for posix_spawn + ) + return 0 + except subprocess.CalledProcessError as proc_exception: + if log_return_code: + _LOGGER.error( + "Command failed (with return code %s): %s", + proc_exception.returncode, + command, + ) + return proc_exception.returncode + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + return -1 + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + return -1 + + +def check_output_or_log(command: str, timeout: int) -> str | None: + """Run a shell command with a timeout and return the output.""" + try: + return_value = subprocess.check_output( + command, + shell=True, # nosec # shell by design + timeout=timeout, + close_fds=False, # required for posix_spawn + ) + return return_value.strip().decode("utf-8") + except subprocess.CalledProcessError as err: + _LOGGER.error( + "Command failed (with return code %s): %s", err.returncode, command + ) + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + + return None diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index bfb74832f907..a650bd6c4fbf 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -42,7 +42,7 @@ async def test_no_covers(caplog: pytest.LogCaptureFixture, hass: HomeAssistant) """Test that the cover does not polls when there's no state command.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ): await setup_test_entity(hass, {}) @@ -53,7 +53,7 @@ async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> N """Test that the cover does not polls when there's no state command.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: await setup_test_entity(hass, {"test": {}}) @@ -66,7 +66,7 @@ async def test_poll_when_cover_has_command_state(hass: HomeAssistant) -> None: """Test that the cover polls when there's a state command.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: await setup_test_entity(hass, {"test": {"command_state": "echo state"}}) diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 5aab14225f19..4643891691f8 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -88,7 +88,7 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: """Ensure command with templates and quotes get rendered properly.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"Works\n", ) as check_output: await setup_test_entities( diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index ac1ae3571230..bc8eadcb22f3 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -323,7 +323,7 @@ async def test_switch_command_state_code_exceptions( """Test that switch state code exceptions are handled correctly.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", side_effect=[ subprocess.TimeoutExpired("cmd", 10), subprocess.SubprocessError(), @@ -356,7 +356,7 @@ async def test_switch_command_state_value_exceptions( """Test that switch state value exceptions are handled correctly.""" with patch( - "homeassistant.components.command_line.subprocess.check_output", + "homeassistant.components.command_line.utils.subprocess.check_output", side_effect=[ subprocess.TimeoutExpired("cmd", 10), subprocess.SubprocessError(), From 71b5ccee233840248b7a78d8c8cb8092d53ad6fd Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 27 Mar 2023 22:22:36 +0300 Subject: [PATCH 0833/1058] Fix generic_hygrostat error at startup (#88764) --- .../components/generic_hygrostat/humidifier.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index dfd6be14e6a5..73d876b354fd 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -22,6 +22,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback from homeassistant.helpers import condition @@ -175,6 +177,15 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): async def _async_startup(event): """Init on startup.""" sensor_state = self.hass.states.get(self._sensor_entity_id) + if sensor_state is None or sensor_state.state in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): + _LOGGER.debug( + "The sensor state is %s, initialization is delayed", + sensor_state.state if sensor_state is not None else "None", + ) + return await self._async_sensor_changed(self._sensor_entity_id, None, sensor_state) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) From 9f04c234146b076e9e89c36f1eaab849c05c9adb Mon Sep 17 00:00:00 2001 From: Jonas Bergler Date: Tue, 28 Mar 2023 08:40:58 +1300 Subject: [PATCH 0834/1058] Support toggling debug logging for custom components (#90340) Co-authored-by: J. Nick Koston --- homeassistant/components/logger/helpers.py | 11 ++-- tests/components/logger/test_websocket_api.py | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index d85486a41e06..df275eaae939 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -66,13 +66,14 @@ def _chattiest_log_level(level1: int, level2: int) -> int: return min(level1, level2) -async def get_integration_loggers(hass: HomeAssistant, domain: str) -> list[str]: +async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]: """Get loggers for an integration.""" - loggers = [f"homeassistant.components.{domain}"] + loggers: set[str] = {f"homeassistant.components.{domain}"} with contextlib.suppress(IntegrationNotFound): integration = await async_get_integration(hass, domain) + loggers.add(integration.pkg_path) if integration.loggers: - loggers.extend(integration.loggers) + loggers.update(integration.loggers) return loggers @@ -188,7 +189,7 @@ class LoggerSettings: if settings.type == LogSettingsType.INTEGRATION: loggers = await get_integration_loggers(hass, domain) else: - loggers = [domain] + loggers = {domain} combined_logs = {logger: LOGSEVERITY[settings.level] for logger in loggers} # Don't override the log levels with the ones from YAML @@ -203,7 +204,7 @@ class LoggerSettings: if settings.type == LogSettingsType.INTEGRATION: loggers = await get_integration_loggers(hass, domain) else: - loggers = [domain] + loggers = {domain} for logger in loggers: combined_logs[logger] = LOGSEVERITY[settings.level] diff --git a/tests/components/logger/test_websocket_api.py b/tests/components/logger/test_websocket_api.py index 1252734df90c..10c1ceb2f200 100644 --- a/tests/components/logger/test_websocket_api.py +++ b/tests/components/logger/test_websocket_api.py @@ -1,6 +1,8 @@ """Tests for Logger Websocket API commands.""" import logging +from unittest.mock import patch +from homeassistant import loader from homeassistant.components.logger.helpers import async_get_domain_config from homeassistant.components.websocket_api import const from homeassistant.core import HomeAssistant @@ -79,6 +81,55 @@ async def test_integration_log_level( } +async def test_custom_integration_log_level( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser +) -> None: + """Test setting integration log level.""" + websocket_client = await hass_ws_client() + assert await async_setup_component(hass, "logger", {}) + + integration = loader.Integration( + hass, + "custom_components.hue", + None, + { + "name": "Hue", + "dependencies": [], + "requirements": [], + "domain": "hue", + "loggers": ["some_other_logger"], + }, + ) + + with patch( + "homeassistant.components.logger.helpers.async_get_integration", + return_value=integration, + ), patch( + "homeassistant.components.logger.websocket_api.async_get_integration", + return_value=integration, + ): + await websocket_client.send_json( + { + "id": 7, + "type": "logger/integration_log_level", + "integration": "hue", + "level": "DEBUG", + "persistence": "none", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + assert async_get_domain_config(hass).overrides == { + "homeassistant.components.hue": logging.DEBUG, + "custom_components.hue": logging.DEBUG, + "some_other_logger": logging.DEBUG, + } + + async def test_integration_log_level_unknown_integration( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_admin_user: MockUser ) -> None: From fb4b35709dda05920488812551e3723a63dca5f2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 27 Mar 2023 22:19:25 +0200 Subject: [PATCH 0835/1058] Add state translations for helpers (#90356) * Add state translations for helpers * Managed via the UI --- homeassistant/components/counter/strings.json | 2 +- homeassistant/components/group/strings.json | 5 +++ .../components/input_boolean/strings.json | 9 +++++ .../components/input_button/strings.json | 17 ++++++++ .../components/input_datetime/strings.json | 39 ++++++++++++++++++- .../components/input_number/strings.json | 38 +++++++++++++++++- .../components/input_select/strings.json | 21 +++++++++- .../components/input_text/strings.json | 34 +++++++++++++++- .../components/schedule/strings.json | 12 ++++++ homeassistant/components/timer/strings.json | 21 ++++++++++ homeassistant/generated/integrations.json | 2 +- homeassistant/strings.json | 3 +- 12 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/input_button/strings.json diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index fb7d34edf484..548d1554080d 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -5,7 +5,7 @@ "name": "[%key:component::counter::title%]", "state_attributes": { "editable": { - "name": "UI-managed", + "name": "[%key:common::generic::ui_managed%]", "state": { "true": "[%key:common::state::yes%]", "false": "[%key:common::state::no%]" diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index e78fe982d5db..9f5054546812 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -169,6 +169,11 @@ "unlocked": "[%key:common::state::unlocked%]", "ok": "[%key:component::binary_sensor::entity_component::problem::state::off%]", "problem": "[%key:component::binary_sensor::entity_component::problem::state::on%]" + }, + "state_attributes": { + "entity_id": { + "name": "Members" + } } } } diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index 8294d7287539..d8e1e133f55a 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -6,6 +6,15 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } } } } diff --git a/homeassistant/components/input_button/strings.json b/homeassistant/components/input_button/strings.json new file mode 100644 index 000000000000..cfd616fd5e73 --- /dev/null +++ b/homeassistant/components/input_button/strings.json @@ -0,0 +1,17 @@ +{ + "title": "Input button", + "entity_component": { + "_": { + "name": "[%key:component::input_button::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } + } + } + } +} diff --git a/homeassistant/components/input_datetime/strings.json b/homeassistant/components/input_datetime/strings.json index 8d51025070e3..0c3a4b0b0d2f 100644 --- a/homeassistant/components/input_datetime/strings.json +++ b/homeassistant/components/input_datetime/strings.json @@ -1 +1,38 @@ -{ "title": "Input datetime" } +{ + "title": "Input datetime", + "entity_component": { + "_": { + "name": "[%key:component::input_datetime::title%]", + "state_attributes": { + "day": { + "name": "Day" + }, + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "hour": { + "name": "Hour" + }, + "minute": { + "name": "Minute" + }, + "month": { + "name": "Month" + }, + "second": { + "name": "Second" + }, + "timestamp": { + "name": "Timestamp" + }, + "year": { + "name": "Year" + } + } + } + } +} diff --git a/homeassistant/components/input_number/strings.json b/homeassistant/components/input_number/strings.json index 35bbbebbdd77..11ed2f8bf10d 100644 --- a/homeassistant/components/input_number/strings.json +++ b/homeassistant/components/input_number/strings.json @@ -1 +1,37 @@ -{ "title": "Input number" } +{ + "title": "Input number", + "entity_component": { + "_": { + "name": "[%key:component::input_number::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "initial": { + "name": "Initial value" + }, + "max": { + "name": "[%key:component::number::entity_component::_::state_attributes::max::name%]" + }, + "min": { + "name": "[%key:component::number::entity_component::_::state_attributes::min::name%]" + }, + "mode": { + "name": "[%key:component::number::entity_component::_::state_attributes::mode::name%]", + "state": { + "auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]", + "box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]", + "slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]" + } + }, + "step": { + "name": "[%key:component::number::entity_component::_::state_attributes::step::name%]" + } + } + } + } +} diff --git a/homeassistant/components/input_select/strings.json b/homeassistant/components/input_select/strings.json index c3cd5c0c71c8..f0dead7a1dd3 100644 --- a/homeassistant/components/input_select/strings.json +++ b/homeassistant/components/input_select/strings.json @@ -1 +1,20 @@ -{ "title": "Input select" } +{ + "title": "Input select", + "entity_component": { + "_": { + "name": "[%key:component::input_select::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "options": { + "name": "[%key:component::select::entity_component::_::state_attributes::options::name%]" + } + } + } + } +} diff --git a/homeassistant/components/input_text/strings.json b/homeassistant/components/input_text/strings.json index dac5995acade..d713c395b67e 100644 --- a/homeassistant/components/input_text/strings.json +++ b/homeassistant/components/input_text/strings.json @@ -1 +1,33 @@ -{ "title": "Input text" } +{ + "title": "Input text", + "entity_component": { + "_": { + "name": "[%key:component::input_text::title%]", + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "max": { + "name": "[%key:component::text::entity_component::_::state_attributes::max::name%]" + }, + "min": { + "name": "[%key:component::text::entity_component::_::state_attributes::min::name%]" + }, + "mode": { + "name": "[%key:component::text::entity_component::_::state_attributes::mode::name%]", + "state": { + "text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]", + "password": "[%key:component::text::entity_component::_::state_attributes::mode::state::password%]" + } + }, + "pattern": { + "name": "[%key:component::text::entity_component::_::state_attributes::pattern::name%]" + } + } + } + } +} diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index f8da366887ac..4c22e5ecead3 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -6,6 +6,18 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "next_event": { + "name": "Next event" + } } } } diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index b6dd2418ada2..217de09a534c 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -6,6 +6,27 @@ "active": "[%key:common::state::active%]", "idle": "[%key:common::state::idle%]", "paused": "[%key:common::state::paused%]" + }, + "state_attributes": { + "duration": { + "name": "Duration" + }, + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + }, + "finishes_at": { + "name": "Finishes at" + }, + "remaining": { + "name": "Remaining" + }, + "restore": { + "name": "Restore" + } } } } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4001adbd2037..1fb801be1248 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6502,7 +6502,6 @@ "config_flow": false }, "input_button": { - "name": "Input Button", "integration_type": "helper", "config_flow": false }, @@ -6578,6 +6577,7 @@ "growatt_server", "homekit_controller", "input_boolean", + "input_button", "input_datetime", "input_number", "input_select", diff --git a/homeassistant/strings.json b/homeassistant/strings.json index ad18b675e073..c4cf0593aaeb 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -1,7 +1,8 @@ { "common": { "generic": { - "model": "Model" + "model": "Model", + "ui_managed": "Managed via UI" }, "state": { "off": "Off", From 182af87f972dc624784a84e24ffd47b582806873 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 27 Mar 2023 22:21:56 +0200 Subject: [PATCH 0836/1058] Refactor matter device entity value conversion (#90368) --- .../components/matter/binary_sensor.py | 36 ++++++++++++------ homeassistant/components/matter/discovery.py | 3 +- homeassistant/components/matter/entity.py | 11 +++++- homeassistant/components/matter/models.py | 11 +----- homeassistant/components/matter/sensor.py | 38 +++++++++++-------- 5 files changed, 60 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index b4d1b867e77e..a82614cbcc69 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -1,6 +1,8 @@ """Matter binary sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Objects import uint from chip.clusters.Types import Nullable, NullValue @@ -15,7 +17,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -30,9 +32,18 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) +@dataclass +class MatterBinarySensorEntityDescription( + BinarySensorEntityDescription, MatterEntityDescription +): + """Describe Matter binary sensor entities.""" + + class MatterBinarySensor(MatterEntity, BinarySensorEntity): """Representation of a Matter binary sensor.""" + entity_description: MatterBinarySensorEntityDescription + @callback def _update_from_device(self) -> None: """Update from device.""" @@ -40,7 +51,7 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_is_on = value @@ -51,52 +62,53 @@ DISCOVERY_SCHEMAS = [ # instead of generic occupancy sensor MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="HueMotionSensor", device_class=BinarySensorDeviceClass.MOTION, name="Motion", + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), vendor_id=(4107,), product_name=("Hue motion sensor",), - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="ContactSensor", device_class=BinarySensorDeviceClass.DOOR, name="Contact", + # value is inverted on matter to what we expect + measurement_to_ha=lambda x: not x, ), entity_class=MatterBinarySensor, required_attributes=(clusters.BooleanState.Attributes.StateValue,), - # value is inverted on matter to what we expect - measurement_to_ha=lambda x: not x, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="OccupancySensor", device_class=BinarySensorDeviceClass.OCCUPANCY, name="Occupancy", + # The first bit = if occupied + measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), - # The first bit = if occupied - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), MatterDiscoverySchema( platform=Platform.BINARY_SENSOR, - entity_description=BinarySensorEntityDescription( + entity_description=MatterBinarySensorEntityDescription( key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, name="Battery Status", + measurement_to_ha=lambda x: x + != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), entity_class=MatterBinarySensor, required_attributes=(clusters.PowerSource.Attributes.BatChargeLevel,), # only add binary battery sensor if a regular percentage based is not available absent_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - measurement_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevel.kOk, ), ] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 36f415dacc01..9df4484e00d2 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -23,7 +23,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, } -SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS.keys()) +SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) @callback @@ -109,7 +109,6 @@ def async_discover_entities( attributes_to_watch=attributes_to_watch, entity_description=schema.entity_description, entity_class=schema.entity_class, - measurement_to_ha=schema.measurement_to_ha, ) # prevent re-discovery of the same attributes diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index a1d67158ab05..bf0a74ef8457 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable +from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast @@ -11,7 +12,7 @@ from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import DOMAIN, ID_TYPE_DEVICE_ID from .helpers import get_device_id @@ -25,6 +26,14 @@ if TYPE_CHECKING: LOGGER = logging.getLogger(__name__) +@dataclass +class MatterEntityDescription(EntityDescription): + """Describe the Matter entity.""" + + # convert the value from the primary attribute to the value used by HA + measurement_to_ha: Callable[[Any], Any] | None = None + + class MatterEntity(Entity): """Entity class for Matter devices.""" diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index eaa9ccf9a099..3ac7f66b83f5 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -1,9 +1,7 @@ """Models used for the Matter integration.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass -from typing import Any from chip.clusters import Objects as clusters from chip.clusters.Objects import ClusterAttributeDescriptor @@ -37,9 +35,6 @@ class MatterEntityInfo: # entity class to use to instantiate the entity entity_class: type - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[SensorValueTypes], SensorValueTypes] | None = None - @property def primary_attribute(self) -> type[ClusterAttributeDescriptor]: """Return Primary Attribute belonging to the entity.""" @@ -50,7 +45,8 @@ class MatterEntityInfo: class MatterDiscoverySchema: """Matter discovery schema. - The Matter endpoint and it's (primary) Attribute for an entity must match these conditions. + The Matter endpoint and its (primary) Attribute + for an entity must match these conditions. """ # specify the hass platform for which this scheme applies (e.g. light, sensor) @@ -95,6 +91,3 @@ class MatterDiscoverySchema: # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False - - # [optional] function to call to convert the value from the primary attribute - measurement_to_ha: Callable[[Any], Any] | None = None diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 34760fbbf134..84e68695d639 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -1,6 +1,8 @@ """Matter sensors.""" from __future__ import annotations +from dataclasses import dataclass + from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue @@ -22,7 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import MatterEntity +from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema @@ -37,10 +39,16 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SENSOR, async_add_entities) +@dataclass +class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescription): + """Describe Matter sensor entities.""" + + class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" _attr_state_class = SensorStateClass.MEASUREMENT + entity_description: MatterSensorEntityDescription @callback def _update_from_device(self) -> None: @@ -49,7 +57,7 @@ class MatterSensor(MatterEntity, SensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self._entity_info.measurement_to_ha: + elif value_convert := self.entity_description.measurement_to_ha: value = value_convert(value) self._attr_native_value = value @@ -58,77 +66,77 @@ class MatterSensor(MatterEntity, SensorEntity): DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="TemperatureSensor", name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PressureSensor", name="Pressure", native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="FlowSensor", name="Flow", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, device_class=SensorDeviceClass.WATER, # what is the device class here ? + measurement_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: x / 10, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="HumiditySensor", name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + measurement_to_ha=lambda x: x / 100, ), entity_class=MatterSensor, required_attributes=( clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, ), - measurement_to_ha=lambda x: x / 100, ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="LightSensor", name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, + measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), entity_class=MatterSensor, required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,), - measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), ), MatterDiscoverySchema( platform=Platform.SENSOR, - entity_description=SensorEntityDescription( + entity_description=MatterSensorEntityDescription( key="PowerSource", name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + # value has double precision + measurement_to_ha=lambda x: int(x / 2), ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), - # value has double precision - measurement_to_ha=lambda x: int(x / 2), ), ] From cb6d384dbafe69b623bedc17940f0932805b0753 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 Mar 2023 23:11:49 +0200 Subject: [PATCH 0837/1058] Workday cleanup (#90267) * clean binary sensor * fix const * clean sensor * Fix tests * Clean up --------- Co-authored-by: Martin Hjelmare --- .../components/workday/binary_sensor.py | 66 +-- homeassistant/components/workday/const.py | 2 +- tests/components/workday/__init__.py | 158 ++++++ .../components/workday/test_binary_sensor.py | 460 ++++++------------ 4 files changed, 332 insertions(+), 354 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index cfd04dd30d14..a2e7f1e589f3 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -92,44 +92,38 @@ def setup_platform( sensor_name: str = config[CONF_NAME] workdays: list[str] = config[CONF_WORKDAYS] - year: int = (get_date(dt.now()) + timedelta(days=days_offset)).year + year: int = (dt.now() + timedelta(days=days_offset)).year obj_holidays: HolidayBase = getattr(holidays, country)(years=year) if province: - if ( - hasattr(obj_holidays, "subdivisions") - and province in obj_holidays.subdivisions - ): + try: obj_holidays = getattr(holidays, country)(subdiv=province, years=year) - else: + except NotImplementedError: LOGGER.error("There is no subdivision %s in country %s", province, country) return # Add custom holidays try: obj_holidays.append(add_holidays) - except TypeError: - LOGGER.debug("No custom holidays or invalid holidays") + except ValueError as error: + LOGGER.error("Could not add custom holidays: %s", error) # Remove holidays - try: - for remove_holiday in remove_holidays: - try: - # is this formatted as a date? - if dt.parse_date(remove_holiday): - # remove holiday by date - removed = obj_holidays.pop(remove_holiday) - LOGGER.debug("Removed %s", remove_holiday) - else: - # remove holiday by name - LOGGER.debug("Treating '%s' as named holiday", remove_holiday) - removed = obj_holidays.pop_named(remove_holiday) - for holiday in removed: - LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) - except KeyError as unmatched: - LOGGER.warning("No holiday found matching %s", unmatched) - except TypeError: - LOGGER.debug("No holidays to remove or invalid holidays") + for remove_holiday in remove_holidays: + try: + # is this formatted as a date? + if dt.parse_date(remove_holiday): + # remove holiday by date + removed = obj_holidays.pop(remove_holiday) + LOGGER.debug("Removed %s", remove_holiday) + else: + # remove holiday by name + LOGGER.debug("Treating '%s' as named holiday", remove_holiday) + removed = obj_holidays.pop_named(remove_holiday) + for holiday in removed: + LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) + except KeyError as unmatched: + LOGGER.warning("No holiday found matching %s", unmatched) LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): @@ -143,19 +137,6 @@ def setup_platform( ) -def day_to_string(day: int) -> str | None: - """Convert day index 0 - 7 to string.""" - try: - return ALLOWED_DAYS[day] - except IndexError: - return None - - -def get_date(input_date: date) -> date: - """Return date. Needed for testing.""" - return input_date - - class IsWorkdaySensor(BinarySensorEntity): """Implementation of a Workday sensor.""" @@ -203,12 +184,9 @@ class IsWorkdaySensor(BinarySensorEntity): self._attr_is_on = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = get_date(dt.now()) + timedelta(days=self._days_offset) + adjusted_date = dt.now() + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 - day_of_week = day_to_string(day) - - if day_of_week is None: - return + day_of_week = ALLOWED_DAYS[day] if self.is_include(day_of_week, adjusted_date): self._attr_is_on = True diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py index 9ebf85f1c2cd..810e1de3934f 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -5,7 +5,7 @@ import logging from homeassistant.const import WEEKDAYS -LOGGER = logging.getLogger(__name__) +LOGGER = logging.getLogger(__package__) ALLOWED_DAYS = WEEKDAYS + ["holiday"] diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py index 57f437e43816..80c8f8d5841b 100644 --- a/tests/components/workday/__init__.py +++ b/tests/components/workday/__init__.py @@ -1 +1,159 @@ """Tests the Home Assistant workday binary sensor.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.workday.const import ( + DEFAULT_EXCLUDES, + DEFAULT_NAME, + DEFAULT_OFFSET, + DEFAULT_WORKDAYS, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +async def init_integration( + hass: HomeAssistant, + config: dict[str, Any], +) -> None: + """Set up the Workday integration in Home Assistant.""" + + await async_setup_component( + hass, "binary_sensor", {"binary_sensor": {"platform": "workday", **config}} + ) + await hass.async_block_till_done() + + +TEST_CONFIG_WITH_PROVINCE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_PROVINCE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "ZZ", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_NO_PROVINCE = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_WITH_STATE = { + "name": DEFAULT_NAME, + "country": "US", + "province": "CA", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_NO_STATE = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_INCLUDE_HOLIDAY = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": ["sat", "sun"], + "days_offset": DEFAULT_OFFSET, + "workdays": ["holiday"], + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_EXAMPLE_1 = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": ["sat", "sun"], + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_EXAMPLE_2 = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": ["mon", "wed", "fri"], + "add_holidays": ["2020-02-24"], + "remove_holidays": [], +} +TEST_CONFIG_REMOVE_HOLIDAY = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["2020-12-25", "2020-11-26"], +} +TEST_CONFIG_REMOVE_NAMED = { + "name": DEFAULT_NAME, + "country": "US", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], +} +TEST_CONFIG_TOMORROW = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": 1, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_DAY_AFTER_TOMORROW = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": 2, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_YESTERDAY = { + "name": DEFAULT_NAME, + "country": "DE", + "excludes": DEFAULT_EXCLUDES, + "days_offset": -1, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": [], + "remove_holidays": [], +} +TEST_CONFIG_INCORRECT_ADD_REMOVE = { + "name": DEFAULT_NAME, + "country": "DE", + "province": "BW", + "excludes": DEFAULT_EXCLUDES, + "days_offset": DEFAULT_OFFSET, + "workdays": DEFAULT_WORKDAYS, + "add_holidays": ["2023-12-32"], + "remove_holidays": ["2023-12-32"], +} diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index f0d2d6b06816..89c98a0c67e1 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -1,350 +1,192 @@ """Tests the Home Assistant workday binary sensor.""" -from datetime import date -from unittest.mock import patch +from datetime import datetime +from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol -import homeassistant.components.workday.binary_sensor as binary_sensor -from homeassistant.setup import setup_component +from homeassistant.components.workday import binary_sensor +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import UTC -from tests.common import assert_setup_component, get_test_home_assistant - -FUNCTION_PATH = "homeassistant.components.workday.binary_sensor.get_date" +from . import ( + TEST_CONFIG_DAY_AFTER_TOMORROW, + TEST_CONFIG_EXAMPLE_1, + TEST_CONFIG_EXAMPLE_2, + TEST_CONFIG_INCLUDE_HOLIDAY, + TEST_CONFIG_INCORRECT_ADD_REMOVE, + TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_NO_PROVINCE, + TEST_CONFIG_NO_STATE, + TEST_CONFIG_REMOVE_HOLIDAY, + TEST_CONFIG_REMOVE_NAMED, + TEST_CONFIG_TOMORROW, + TEST_CONFIG_WITH_PROVINCE, + TEST_CONFIG_WITH_STATE, + TEST_CONFIG_YESTERDAY, + init_integration, +) -class TestWorkdaySetup: - """Test class for workday sensor.""" +async def test_valid_country_yaml() -> None: + """Test valid country from yaml.""" + # Invalid UTF-8, must not contain U+D800 to U+DFFF + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("\ud800") + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("\udfff") + # Country MUST NOT be empty + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("") + # Country must be supported by holidays + with pytest.raises(vol.Invalid): + binary_sensor.valid_country("HomeAssistantLand") - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - # Set valid default config for test - self.config_province = { - "binary_sensor": {"platform": "workday", "country": "DE", "province": "BW"} - } +@pytest.mark.parametrize( + ("config", "expected_state"), + [ + (TEST_CONFIG_WITH_PROVINCE, "off"), + (TEST_CONFIG_NO_PROVINCE, "off"), + (TEST_CONFIG_WITH_STATE, "on"), + (TEST_CONFIG_NO_STATE, "on"), + (TEST_CONFIG_EXAMPLE_1, "on"), + (TEST_CONFIG_EXAMPLE_2, "off"), + (TEST_CONFIG_TOMORROW, "off"), + (TEST_CONFIG_DAY_AFTER_TOMORROW, "off"), + (TEST_CONFIG_YESTERDAY, "on"), + ], +) +async def test_setup( + hass: HomeAssistant, + config: dict[str, Any], + expected_state: str, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Monday + await init_integration(hass, config) - self.config_noprovince = { - "binary_sensor": {"platform": "workday", "country": "DE"} - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == expected_state + assert state.attributes == { + "friendly_name": "Workday Sensor", + "workdays": config["workdays"], + "excludes": config["excludes"], + "days_offset": config["days_offset"], + } - self.config_invalidprovince = { + +async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: + """Test setup invalid province with import.""" + + await async_setup_component( + hass, + "binary_sensor", + { "binary_sensor": { "platform": "workday", "country": "DE", "province": "invalid", } - } + }, + ) + await hass.async_block_till_done() - self.config_state = { - "binary_sensor": {"platform": "workday", "country": "US", "province": "CA"} - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state is None - self.config_nostate = { - "binary_sensor": {"platform": "workday", "country": "US"} - } - self.config_includeholiday = { - "binary_sensor": { - "platform": "workday", - "country": "DE", - "province": "BW", - "workdays": ["holiday"], - "excludes": ["sat", "sun"], - } - } +async def test_setup_with_working_holiday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCLUDE_HOLIDAY) - self.config_example1 = { - "binary_sensor": { - "platform": "workday", - "country": "US", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun"], - } - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" - self.config_example2 = { - "binary_sensor": { - "platform": "workday", - "country": "DE", - "province": "BW", - "workdays": ["mon", "wed", "fri"], - "excludes": ["sat", "sun", "holiday"], - "add_holidays": ["2020-02-24"], - } - } - self.config_remove_holidays = { - "binary_sensor": { - "platform": "workday", - "country": "US", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun", "holiday"], - "remove_holidays": ["2020-12-25", "2020-11-26"], - } - } +async def test_setup_add_holiday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2020, 2, 24, 12, tzinfo=UTC)) # Monday + await init_integration(hass, TEST_CONFIG_EXAMPLE_2) - self.config_remove_named_holidays = { - "binary_sensor": { - "platform": "workday", - "country": "US", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun", "holiday"], - "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], - } - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "off" - self.config_tomorrow = { - "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": 1} - } - self.config_day_after_tomorrow = { - "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": 2} - } +async def test_setup_remove_holiday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2020, 12, 25, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_REMOVE_HOLIDAY) - self.config_yesterday = { - "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": -1} - } + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - def test_valid_country(self): - """Test topic name/filter validation.""" - # Invalid UTF-8, must not contain U+D800 to U+DFFF - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\ud800") - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\udfff") - # Country MUST NOT be empty - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("") - # Country must be supported by holidays - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("HomeAssistantLand") +async def test_setup_remove_holiday_named( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2020, 12, 25, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_REMOVE_NAMED) - # Valid country code validation must not raise an exception - for country in ("IM", "LI", "US"): - assert binary_sensor.valid_country(country) == country + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "on" - def test_setup_component_province(self): - """Set up workday component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity is not None +async def test_setup_day_after_tomorrow( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test setup from various configs.""" + freezer.move_to(datetime(2022, 5, 27, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_DAY_AFTER_TOMORROW) - # Freeze time to a workday - Mar 15th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 15)) - def test_workday_province(self, mock_date): - """Test if workdays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() + state = hass.states.get("binary_sensor.workday_sensor") + assert state.state == "off" - self.hass.start() - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" +async def test_setup_faulty_province( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with faulty province.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_PROVINCE) - # Freeze time to a weekend - Mar 12th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 12)) - def test_weekend_province(self, mock_date): - """Test if weekends are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() + state = hass.states.get("binary_sensor.workday_sensor") + assert state is None - self.hass.start() + assert "There is no subdivision" in caplog.text - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - # Freeze time to a public holiday in province BW - Jan 6th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) - def test_public_holiday_province(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_province) - self.hass.block_till_done() +async def test_setup_incorrect_add_remove( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test setup with incorrect add/remove custom holiday.""" + freezer.move_to(datetime(2017, 1, 6, 12, tzinfo=UTC)) # Friday + await init_integration(hass, TEST_CONFIG_INCORRECT_ADD_REMOVE) - self.hass.start() + hass.states.get("binary_sensor.workday_sensor") - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - def test_setup_component_noprovince(self): - """Set up workday component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_noprovince) - self.hass.block_till_done() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity is not None - - # Freeze time to a public holiday in province BW - Jan 6th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) - def test_public_holiday_noprovince(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_noprovince) - self.hass.block_till_done() - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a public holiday in state CA - Mar 31st, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 31)) - def test_public_holiday_state(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_state) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - # Freeze time to a public holiday in state CA - Mar 31st, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 3, 31)) - def test_public_holiday_nostate(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_nostate) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - def test_setup_component_invalidprovince(self): - """Set up workday component.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_invalidprovince) - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity is None - - # Freeze time to a public holiday in province BW - Jan 6th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 1, 6)) - def test_public_holiday_includeholiday(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_includeholiday) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a saturday to test offset - Aug 5th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) - def test_tomorrow(self, mock_date): - """Test if tomorrow are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_tomorrow) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - # Freeze time to a saturday to test offset - Aug 5th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) - def test_day_after_tomorrow(self, mock_date): - """Test if the day after tomorrow are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_day_after_tomorrow) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a saturday to test offset - Aug 5th, 2017 - @patch(FUNCTION_PATH, return_value=date(2017, 8, 5)) - def test_yesterday(self, mock_date): - """Test if yesterday are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_yesterday) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to a Presidents day to test Holiday on a Work day - Jan 20th, 2020 - # Presidents day Feb 17th 2020 is mon. - @patch(FUNCTION_PATH, return_value=date(2020, 2, 17)) - def test_config_example1_holiday(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_example1) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to test tue - Feb 18th, 2020 - @patch(FUNCTION_PATH, return_value=date(2020, 2, 18)) - def test_config_example2_tue(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_example2) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - # Freeze time to test mon, but added as holiday - Feb 24th, 2020 - @patch(FUNCTION_PATH, return_value=date(2020, 2, 24)) - def test_config_example2_add_holiday(self, mock_date): - """Test if public holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_example2) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "off" - - def test_day_to_string(self): - """Test if day_to_string is behaving correctly.""" - assert binary_sensor.day_to_string(0) == "mon" - assert binary_sensor.day_to_string(1) == "tue" - assert binary_sensor.day_to_string(7) == "holiday" - assert binary_sensor.day_to_string(8) is None - - # Freeze time to test Fri, but remove holiday - December 25, 2020 - @patch(FUNCTION_PATH, return_value=date(2020, 12, 25)) - def test_config_remove_holidays_xmas(self, mock_date): - """Test if removed holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component(self.hass, "binary_sensor", self.config_remove_holidays) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" - - # Freeze time to test Fri, but remove holiday by name - Christmas - @patch(FUNCTION_PATH, return_value=date(2020, 12, 25)) - def test_config_remove_named_holidays_xmas(self, mock_date): - """Test if removed by name holidays are reported correctly.""" - with assert_setup_component(1, "binary_sensor"): - setup_component( - self.hass, "binary_sensor", self.config_remove_named_holidays - ) - - self.hass.start() - - entity = self.hass.states.get("binary_sensor.workday_sensor") - assert entity.state == "on" + assert ( + "Could not add custom holidays: Cannot parse date from string '2023-12-32'" + in caplog.text + ) + assert "No holiday found matching '2023-12-32'" in caplog.text From 058a2c9d83870f81af88632403aa93f9abab676a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 12:41:51 -1000 Subject: [PATCH 0838/1058] Bump yalexs-ble to 2.1.12 (#90381) --- 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 7884ba6a4ba9..5528b7935384 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.2.7", "yalexs-ble==2.1.9"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.12"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 5c7adf09e370..6cff0dd8c69b 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.1.9"] + "requirements": ["yalexs-ble==2.1.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 91238f06f229..76444267b4ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.9 +yalexs-ble==2.1.12 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f16cbf7c37cd..451b2353a617 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1908,7 +1908,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.9 +yalexs-ble==2.1.12 # homeassistant.components.august yalexs==1.2.7 From 1cd2fe9d28f1cf60c2f7987dce5fbf7344153571 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 12:44:34 -1000 Subject: [PATCH 0839/1058] Bump aiodiscover to 1.4.15 (#90383) --- homeassistant/components/dhcp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index a5ee449dda62..2e1d758746db 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.14"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.4.15"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ae190115b2aa..518bea69fbeb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ PyJWT==2.6.0 PyNaCl==1.5.0 PyTurboJPEG==1.6.7 -aiodiscover==1.4.14 +aiodiscover==1.4.15 aiohttp==3.8.4 aiohttp_cors==0.7.0 astral==2.2 diff --git a/requirements_all.txt b/requirements_all.txt index 76444267b4ac..77974c3ad7ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -137,7 +137,7 @@ aiobafi6==0.8.0 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.14 +aiodiscover==1.4.15 # homeassistant.components.dnsip # homeassistant.components.minecraft_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 451b2353a617..cfb677ed57de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -127,7 +127,7 @@ aiobafi6==0.8.0 aiobotocore==2.1.0 # homeassistant.components.dhcp -aiodiscover==1.4.14 +aiodiscover==1.4.15 # homeassistant.components.dnsip # homeassistant.components.minecraft_server From a361fba8f5859be3e60943c4011a8bdbde26db1b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Mar 2023 00:48:14 +0200 Subject: [PATCH 0840/1058] Bump nextcloudmonitor to 1.4.0 (#90372) --- homeassistant/components/nextcloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextcloud/manifest.json b/homeassistant/components/nextcloud/manifest.json index 72e992277c69..fe4366c334d1 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.1.0"] + "requirements": ["nextcloudmonitor==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 77974c3ad7ed..c2838240e21c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1189,7 +1189,7 @@ neurio==0.3.1 nexia==2.0.6 # homeassistant.components.nextcloud -nextcloudmonitor==1.1.0 +nextcloudmonitor==1.4.0 # homeassistant.components.discord nextcord==2.0.0a8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfb677ed57de..2759d786e863 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -888,7 +888,7 @@ nettigo-air-monitor==2.1.0 nexia==2.0.6 # homeassistant.components.nextcloud -nextcloudmonitor==1.1.0 +nextcloudmonitor==1.4.0 # homeassistant.components.discord nextcord==2.0.0a8 From 59113a3e4c368d01a0f047de9fa17d617a7d58b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 12:50:11 -1000 Subject: [PATCH 0841/1058] Bump flux_led to 0.28.36 (#90380) --- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 5bb47fbe7988..a9b1ef61db59 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -51,5 +51,5 @@ "iot_class": "local_push", "loggers": ["flux_led"], "quality_scale": "platinum", - "requirements": ["flux_led==0.28.35"] + "requirements": ["flux_led==0.28.36"] } diff --git a/requirements_all.txt b/requirements_all.txt index c2838240e21c..51d948a2dea2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -725,7 +725,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux_led==0.28.35 +flux_led==0.28.36 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2759d786e863..ceafe5d1738c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -553,7 +553,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux_led==0.28.35 +flux_led==0.28.36 # homeassistant.components.homekit # homeassistant.components.recorder From 5b4663d2ca729545635db0e4fc29b68ec961b2b7 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Mon, 27 Mar 2023 20:27:54 -0300 Subject: [PATCH 0842/1058] Bump proxmoxer to 2.0.1 in Proxmox VE (#90378) --- homeassistant/components/proxmoxve/__init__.py | 3 +-- homeassistant/components/proxmoxve/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index f8e350f2b157..7ea4cac58dd0 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -3,8 +3,7 @@ from __future__ import annotations from datetime import timedelta -from proxmoxer import ProxmoxAPI -from proxmoxer.backends.https import AuthenticationError +from proxmoxer import AuthenticationError, ProxmoxAPI from proxmoxer.core import ResourceException import requests.exceptions from requests.exceptions import ConnectTimeout, SSLError diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 1c6806957950..8cf3bc7932d4 100644 --- a/homeassistant/components/proxmoxve/manifest.json +++ b/homeassistant/components/proxmoxve/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/proxmoxve", "iot_class": "local_polling", "loggers": ["proxmoxer"], - "requirements": ["proxmoxer==1.3.1"] + "requirements": ["proxmoxer==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 51d948a2dea2..e0df5121a017 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1397,7 +1397,7 @@ proliphix==0.4.1 prometheus_client==0.7.1 # homeassistant.components.proxmoxve -proxmoxer==1.3.1 +proxmoxer==2.0.1 # homeassistant.components.hardware psutil-home-assistant==0.0.1 From e27d3c952396b52c5afe714f8379df2bf065d052 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Tue, 28 Mar 2023 06:18:47 +0000 Subject: [PATCH 0843/1058] Improve airq handling of DeviceInfo (#90232) * Reduce data sharing between ConfigFlow and DataUpdateCoordinator Instead of fetching device information from the device once in `ConfigFlow` and then piping it through in `ConfigEntry.data`, only use as much as needed in `ConfigFlow.async_step_user`, then fetch again in `AirQCoordinator._async_update_data` if a key is missing. Additionally, factor `AirQCoordinator` out into a sumbodule. Add a simple test for `AirQCoordinator.device_info` update. Positive side effect: `AirQCoordinator.device_info` is updated explicitly, instead of dumping the entire content of (a fully compatible) `TypedDict`, retrieved from `aioairq`. * Remove tests ill-suited to this PR `test_config_flow.test_duplicate_error` slipped through by mistake, while `test_coordinator.test_fetch_device_info_on_first_update` may need a more thoroughly suite of accompanying tests * Ignore airq/coordinator.py ...newly separated from airq/__init__.py, that's already in this list * Reorder files alphabetically --- .coveragerc | 1 + homeassistant/components/airq/__init__.py | 48 +-------------- homeassistant/components/airq/config_flow.py | 5 +- homeassistant/components/airq/coordinator.py | 61 ++++++++++++++++++++ tests/components/airq/test_config_flow.py | 5 +- 5 files changed, 68 insertions(+), 52 deletions(-) create mode 100644 homeassistant/components/airq/coordinator.py diff --git a/.coveragerc b/.coveragerc index da7cc42ba15f..520b87b08b90 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,7 @@ omit = homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py homeassistant/components/airq/__init__.py + homeassistant/components/airq/coordinator.py homeassistant/components/airq/sensor.py homeassistant/components/airthings/__init__.py homeassistant/components/airthings/sensor.py diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index 4bc64e1e8251..06d7ba30749c 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -1,58 +1,16 @@ """The air-Q integration.""" from __future__ import annotations -from datetime import timedelta -import logging - -from aioairq import AirQ - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import AirQCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -class AirQCoordinator(DataUpdateCoordinator): - """Coordinator is responsible for querying the device at a specified route.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - ) -> None: - """Initialise a custom coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), - ) - session = async_get_clientsession(hass) - self.airq = AirQ( - entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session - ) - self.device_id = entry.unique_id - assert self.device_id is not None - self.device_info = DeviceInfo( - manufacturer=MANUFACTURER, - identifiers={(DOMAIN, self.device_id)}, - ) - self.device_info.update(entry.data["device_info"]) - - async def _async_update_data(self) -> dict: - """Fetch the data from the device.""" - data = await self.airq.get(TARGET_ROUTE) - return self.airq.drop_uncertainties_from_data(data) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up air-Q from a config entry.""" diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 90a6b9e0555f..41eda912e982 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -74,12 +74,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) device_info = await airq.fetch_device_info() - await self.async_set_unique_id(device_info.pop("id")) + await self.async_set_unique_id(device_info["id"]) self._abort_if_unique_id_configured() return self.async_create_entry( - title=device_info["name"], - data=user_input | {"device_info": device_info}, + title=device_info["name"], data=user_input ) return self.async_show_form( diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py new file mode 100644 index 000000000000..78e9580c6310 --- /dev/null +++ b/homeassistant/components/airq/coordinator.py @@ -0,0 +1,61 @@ +"""The air-Q integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioairq import AirQ + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AirQCoordinator(DataUpdateCoordinator): + """Coordinator is responsible for querying the device at a specified route.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialise a custom coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + session = async_get_clientsession(hass) + self.airq = AirQ( + entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session + ) + self.device_id = entry.unique_id + assert self.device_id is not None + self.device_info = DeviceInfo( + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, self.device_id)}, + ) + + async def _async_update_data(self) -> dict: + """Fetch the data from the device.""" + if "name" not in self.device_info: + info = await self.airq.fetch_device_info() + self.device_info.update( + DeviceInfo( + name=info["name"], + model=info["model"], + sw_version=info["sw_version"], + hw_version=info["hw_version"], + ) + ) + + data = await self.airq.get(TARGET_ROUTE) + return self.airq.drop_uncertainties_from_data(data) diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 52bd5cd37fd7..af71dc813e20 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -24,9 +24,6 @@ TEST_DEVICE_INFO = DeviceInfo( sw_version="sw", hw_version="hw", ) -TEST_DATA_OUT = TEST_USER_DATA | { - "device_info": {k: v for k, v in TEST_DEVICE_INFO.items() if k != "id"} -} async def test_form(hass: HomeAssistant) -> None: @@ -48,7 +45,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_DEVICE_INFO["name"] - assert result2["data"] == TEST_DATA_OUT + assert result2["data"] == TEST_USER_DATA async def test_form_invalid_auth(hass: HomeAssistant) -> None: From 3dd3cb195fe64ecda642d6a811ce8a9d5a832108 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 27 Mar 2023 19:23:53 -1100 Subject: [PATCH 0844/1058] Set default value for some Fronius entities (#89475) --- .../components/fronius/coordinator.py | 4 +- homeassistant/components/fronius/sensor.py | 199 ++++++++++-------- tests/components/fronius/test_sensor.py | 26 ++- 3 files changed, 137 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/fronius/coordinator.py b/homeassistant/components/fronius/coordinator.py index 16e55f12726d..94fd5f256aad 100644 --- a/homeassistant/components/fronius/coordinator.py +++ b/homeassistant/components/fronius/coordinator.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Any, TypeVar from pyfronius import BadStatusError, FroniusError -from homeassistant.components.sensor import SensorEntityDescription from homeassistant.core import callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,6 +24,7 @@ from .sensor import ( OHMPILOT_ENTITY_DESCRIPTIONS, POWER_FLOW_ENTITY_DESCRIPTIONS, STORAGE_ENTITY_DESCRIPTIONS, + FroniusSensorEntityDescription, ) if TYPE_CHECKING: @@ -41,7 +41,7 @@ class FroniusCoordinatorBase( default_interval: timedelta error_interval: timedelta - valid_descriptions: list[SensorEntityDescription] + valid_descriptions: list[FroniusSensorEntityDescription] MAX_FAILED_UPDATES = 3 diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 8c7055db8b5f..e7f938953706 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -1,6 +1,7 @@ """Support for Fronius devices.""" from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final from homeassistant.components.sensor import ( @@ -25,6 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -77,113 +79,128 @@ async def async_setup_entry( ) -INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +@dataclass +class FroniusSensorEntityDescription(SensorEntityDescription): + """Describes Fronius sensor entity.""" + + default_value: StateType | None = None + + +INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_day", name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_year", name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_total", name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="frequency_ac", name="Frequency AC", + default_value=0, native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac", name="Current AC", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc", name="Current DC", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc_2", name="Current DC 2", + default_value=0, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_ac", name="Power AC", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac", name="Voltage AC", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc", name="Voltage DC", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_2", name="Voltage DC 2", + default_value=0, native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), # device status entities - SensorEntityDescription( + FroniusSensorEntityDescription( key="inverter_state", name="Inverter state", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="error_code", name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="status_code", name="Status code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="led_state", name="LED state", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="led_color", name="LED color", entity_category=EntityCategory.DIAGNOSTIC, @@ -191,20 +208,20 @@ INVERTER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +LOGGER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="co2_factor", name="CO₂ factor", state_class=SensorStateClass.MEASUREMENT, icon="mdi:molecule-co2", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="cash_factor", name="Grid export tariff", state_class=SensorStateClass.MEASUREMENT, icon="mdi:cash-plus", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="delivery_factor", name="Grid import tariff", state_class=SensorStateClass.MEASUREMENT, @@ -212,8 +229,8 @@ LOGGER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="current_ac_phase_1", name="Current AC phase 1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -221,7 +238,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac_phase_2", name="Current AC phase 2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -229,7 +246,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_ac_phase_3", name="Current AC phase 3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -237,7 +254,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_reactive_ac_consumed", name="Energy reactive AC consumed", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, @@ -245,7 +262,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_reactive_ac_produced", name="Energy reactive AC produced", native_unit_of_measurement=ENERGY_VOLT_AMPERE_REACTIVE_HOUR, @@ -253,7 +270,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_ac_minus", name="Energy real AC minus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -261,7 +278,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_ac_plus", name="Energy real AC plus", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -269,33 +286,33 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_consumed", name="Energy real consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_real_produced", name="Energy real produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="frequency_phase_average", name="Frequency phase average", native_unit_of_measurement=UnitOfFrequency.HERTZ, device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="meter_location", name="Meter location", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_1", name="Power apparent phase 1", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -304,7 +321,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_2", name="Power apparent phase 2", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -313,7 +330,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent_phase_3", name="Power apparent phase 3", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -322,7 +339,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_apparent", name="Power apparent", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, @@ -331,34 +348,34 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_1", name="Power factor phase 1", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_2", name="Power factor phase 2", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor_phase_3", name="Power factor phase 3", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_factor", name="Power factor", device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_1", name="Power reactive phase 1", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -367,7 +384,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_2", name="Power reactive phase 2", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -376,7 +393,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive_phase_3", name="Power reactive phase 3", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -385,7 +402,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_reactive", name="Power reactive", native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, @@ -394,7 +411,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:flash-outline", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_1", name="Power real phase 1", native_unit_of_measurement=UnitOfPower.WATT, @@ -402,7 +419,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_2", name="Power real phase 2", native_unit_of_measurement=UnitOfPower.WATT, @@ -410,7 +427,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_phase_3", name="Power real phase 3", native_unit_of_measurement=UnitOfPower.WATT, @@ -418,14 +435,14 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real", name="Power real", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_1", name="Voltage AC phase 1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -433,7 +450,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_2", name="Voltage AC phase 2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -441,7 +458,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_3", name="Voltage AC phase 3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -449,7 +466,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_12", name="Voltage AC phase 1-2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -457,7 +474,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_23", name="Voltage AC phase 2-3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -465,7 +482,7 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_ac_phase_to_phase_31", name="Voltage AC phase 3-1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -475,47 +492,47 @@ METER_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ ), ] -OHMPILOT_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_real_ac_consumed", name="Energy consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_real_ac", name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="temperature_channel_1", name="Temperature channel 1", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="error_code", name="Error code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_code", name="State code", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_message", name="State message", entity_category=EntityCategory.DIAGNOSTIC, ), ] -POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="energy_day", name="Energy day", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -523,7 +540,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_year", name="Energy year", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -531,7 +548,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="energy_total", name="Energy total", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -539,69 +556,75 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="meter_mode", name="Meter mode", entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_battery", name="Power battery", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_grid", name="Power grid", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_load", name="Power load", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="power_photovoltaics", name="Power photovoltaics", + default_value=0, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="relative_autonomy", name="Relative autonomy", + default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:home-circle-outline", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="relative_self_consumption", name="Relative self consumption", + default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:solar-power", ), ] -STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( +STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ + FroniusSensorEntityDescription( key="capacity_maximum", name="Capacity maximum", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="capacity_designed", name="Capacity designed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_category=EntityCategory.DIAGNOSTIC, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="current_dc", name="Current DC", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -609,7 +632,7 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc", name="Voltage DC", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -617,7 +640,7 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, icon="mdi:current-dc", ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_maximum_cell", name="Voltage DC maximum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -626,7 +649,7 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:current-dc", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="voltage_dc_minimum_cell", name="Voltage DC minimum cell", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -635,14 +658,14 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ icon="mdi:current-dc", entity_registry_enabled_default=False, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="state_of_charge", name="State of charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), - SensorEntityDescription( + FroniusSensorEntityDescription( key="temperature_cell", name="Temperature cell", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -655,7 +678,8 @@ STORAGE_ENTITY_DESCRIPTIONS: list[SensorEntityDescription] = [ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEntity): """Defines a Fronius coordinator entity.""" - entity_descriptions: list[SensorEntityDescription] + entity_description: FroniusSensorEntityDescription + entity_descriptions: list[FroniusSensorEntityDescription] _attr_has_entity_name = True @@ -682,7 +706,11 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn new_value = self.coordinator.data[self.solar_net_id][ self.entity_description.key ]["value"] - return round(new_value, 4) if isinstance(new_value, float) else new_value + if new_value is None: + return self.entity_description.default_value + if isinstance(new_value, float): + return round(new_value, 4) + return new_value @callback def _handle_coordinator_update(self) -> None: @@ -690,7 +718,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn try: self._attr_native_value = self._get_entity_value() except KeyError: - return + # sets state to `None` if no default_value is defined in entity description + self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 6f7b7793882f..ef881b552fa7 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -62,16 +62,16 @@ async def test_symo_inverter( assert_state("sensor.symo_20_power_ac", 1190) assert_state("sensor.symo_20_voltage_ac", 227.90) - # Third test at nighttime - additional AC entities aren't changed + # Third test at nighttime - additional AC entities default to 0 mock_responses(aioclient_mock, night=True) async_fire_time_changed( hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() - assert_state("sensor.symo_20_current_ac", 5.19) - assert_state("sensor.symo_20_frequency_ac", 49.94) - assert_state("sensor.symo_20_power_ac", 1190) - assert_state("sensor.symo_20_voltage_ac", 227.90) + assert_state("sensor.symo_20_current_ac", 0) + assert_state("sensor.symo_20_frequency_ac", 0) + assert_state("sensor.symo_20_power_ac", 0) + assert_state("sensor.symo_20_voltage_ac", 0) async def test_symo_logger( @@ -190,6 +190,22 @@ async def test_symo_power_flow( assert_state("sensor.solarnet_relative_autonomy", 39.4708) assert_state("sensor.solarnet_relative_self_consumption", 100) + # Third test at nighttime - default values are used + mock_responses(aioclient_mock, night=True) + async_fire_time_changed( + hass, dt.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 + assert_state("sensor.solarnet_energy_day", 10828) + assert_state("sensor.solarnet_energy_total", 44186900) + assert_state("sensor.solarnet_energy_year", 25507686) + assert_state("sensor.solarnet_power_grid", 975.31) + assert_state("sensor.solarnet_power_load", -975.31) + assert_state("sensor.solarnet_power_photovoltaics", 0) + assert_state("sensor.solarnet_relative_autonomy", 0) + assert_state("sensor.solarnet_relative_self_consumption", 0) + async def test_gen24(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test Fronius Gen24 inverter entities.""" From dc37d921972e444a7fcb88441dabd0d7947a19b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 20:34:56 -1000 Subject: [PATCH 0845/1058] Add lru stats to the profiler integration (#90388) --- homeassistant/components/profiler/__init__.py | 72 ++++++++++++++++++- .../components/profiler/services.yaml | 3 + tests/components/profiler/test_init.py | 32 +++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index fab6932edd20..b838f67d02e3 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -1,6 +1,8 @@ """The profiler integration.""" import asyncio +from contextlib import suppress from datetime import timedelta +from functools import _lru_cache_wrapper import logging import reprlib import sys @@ -9,6 +11,7 @@ import time import traceback from typing import Any, cast +from lru import LRU # pylint: disable=no-name-in-module import voluptuous as vol from homeassistant.components import persistent_notification @@ -27,9 +30,21 @@ SERVICE_MEMORY = "memory" SERVICE_START_LOG_OBJECTS = "start_log_objects" SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" +SERVICE_LRU_STATS = "lru_stats" SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" +_LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ + +_KNOWN_LRU_CLASSES = ( + "EventDataManager", + "EventTypeManager", + "StatesMetaManager", + "StateAttributesManager", + "StatisticsMetaManager", + "DomainData", + "IntegrationMatcher", +) SERVICES = ( SERVICE_START, @@ -37,6 +52,7 @@ SERVICES = ( SERVICE_START_LOG_OBJECTS, SERVICE_STOP_LOG_OBJECTS, SERVICE_DUMP_LOG_OBJECTS, + SERVICE_LRU_STATS, SERVICE_LOG_THREAD_FRAMES, SERVICE_LOG_EVENT_LOOP_SCHEDULED, ) @@ -47,6 +63,7 @@ CONF_SECONDS = "seconds" LOG_INTERVAL_SUB = "log_interval_subscription" + _LOGGER = logging.getLogger(__name__) @@ -123,6 +140,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: notification_id="profile_object_dump", ) + def _get_function_absfile(func: Any) -> str: + """Get the absolute file path of a function.""" + import inspect # pylint: disable=import-outside-toplevel + + abs_file = "unknown" + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + def _lru_stats(call: ServiceCall) -> None: + """Log the stats of all lru caches.""" + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import objgraph # pylint: disable=import-outside-toplevel + + for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): + lru = cast(_lru_cache_wrapper, lru) + _LOGGER.critical( + "Cache stats for lru_cache %s at %s: %s", + lru.__wrapped__, + _get_function_absfile(lru.__wrapped__), + lru.cache_info(), + ) + + for _class in _KNOWN_LRU_CLASSES: + for class_with_lru_attr in objgraph.by_type(_class): + for maybe_lru in class_with_lru_attr.__dict__.values(): + if isinstance(maybe_lru, LRU): + _LOGGER.critical( + "Cache stats for LRU %s at %s: %s", + type(class_with_lru_attr), + _get_function_absfile(class_with_lru_attr), + maybe_lru.get_stats(), + ) + + persistent_notification.create( + hass, + ( + "LRU cache states have been dumped to the log. See [the" + " logs](/config/logs) to review the stats." + ), + title="LRU stats completed", + notification_id="profile_lru_stats", + ) + async def _async_dump_thread_frames(call: ServiceCall) -> None: """Log all thread frames.""" frames = sys._current_frames() # pylint: disable=protected-access @@ -202,6 +265,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=vol.Schema({vol.Required(CONF_TYPE): str}), ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LRU_STATS, + _lru_stats, + ) + async_register_admin_service( hass, DOMAIN, @@ -323,4 +393,4 @@ def _log_objects(*_): # integration is used at a time import objgraph # pylint: disable=import-outside-toplevel - _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=100)) + _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 8d9ae35ed107..1105842891ff 100644 --- a/homeassistant/components/profiler/services.yaml +++ b/homeassistant/components/profiler/services.yaml @@ -51,6 +51,9 @@ dump_log_objects: example: State selector: text: +lru_stats: + name: Log LRU stats + description: Log the stats of all lru caches. log_thread_frames: name: Log thread frames description: Log the current frames for all threads. diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 0f46f306fefb..2c283463b620 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -1,9 +1,11 @@ """Test the Profiler config flow.""" from datetime import timedelta +from functools import lru_cache import os import sys from unittest.mock import patch +from lru import LRU # pylint: disable=no-name-in-module import py import pytest @@ -12,6 +14,7 @@ from homeassistant.components.profiler import ( SERVICE_DUMP_LOG_OBJECTS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, SERVICE_LOG_THREAD_FRAMES, + SERVICE_LRU_STATS, SERVICE_MEMORY, SERVICE_START, SERVICE_START_LOG_OBJECTS, @@ -228,3 +231,32 @@ async def test_log_scheduled( assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test logging lru stats.""" + + 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() + + @lru_cache(maxsize=1) + def _dummy_test_lru_stats(): + return 1 + + class DomainData: + def __init__(self): + self._data = LRU(1) + + domain_data = DomainData() + assert hass.services.has_service(DOMAIN, SERVICE_LRU_STATS) + + await hass.services.async_call(DOMAIN, SERVICE_LRU_STATS, blocking=True) + + assert "DomainData" in caplog.text + assert "(0, 0)" in caplog.text + assert "_dummy_test_lru_stats" in caplog.text + assert "CacheInfo" in caplog.text + del domain_data From 33fef5592fba45117bf6555385a20d5ed58cc56b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 28 Mar 2023 08:36:42 +0200 Subject: [PATCH 0846/1058] Refactor GIOS sensor platform (#89389) --- homeassistant/components/gios/const.py | 3 - homeassistant/components/gios/sensor.py | 143 ++++++---- homeassistant/components/gios/strings.json | 50 ++++ tests/components/gios/test_sensor.py | 314 ++++++++++----------- 4 files changed, 300 insertions(+), 210 deletions(-) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 895775495f96..33ddfae6fe1a 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -16,9 +16,6 @@ URL = "http://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id API_TIMEOUT: Final = 30 -ATTR_INDEX: Final = "index" -ATTR_STATION: Final = "station" - ATTR_C6H6: Final = "c6h6" ATTR_CO: Final = "co" ATTR_NO2: Final = "no2" diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 9c73b358897f..7cf4b7e7c600 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -4,7 +4,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, cast + +from gios.model import GiosSensors from homeassistant.components.sensor import ( DOMAIN as PLATFORM, @@ -14,11 +15,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_NAME, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONF_NAME, -) +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType @@ -32,13 +29,11 @@ from .const import ( ATTR_AQI, ATTR_C6H6, ATTR_CO, - ATTR_INDEX, ATTR_NO2, ATTR_O3, ATTR_PM10, ATTR_PM25, ATTR_SO2, - ATTR_STATION, ATTRIBUTION, DOMAIN, MANUFACTURER, @@ -49,17 +44,24 @@ _LOGGER = logging.getLogger(__name__) @dataclass -class GiosSensorEntityDescription(SensorEntityDescription): +class GiosSensorRequiredKeysMixin: + """Class for GIOS entity required keys.""" + + value: Callable[[GiosSensors], StateType] + + +@dataclass +class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKeysMixin): """Class describing GIOS sensor entities.""" - value: Callable | None = round + subkey: str | None = None SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, name="AQI", - value=None, + value=lambda sensors: sensors.aqi.value if sensors.aqi else None, icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], @@ -68,6 +70,8 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_C6H6, name="C6H6", + value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, + suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -75,44 +79,107 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_CO, name="CO", + value=lambda sensors: sensors.co.value if sensors.co else None, + suggested_display_precision=0, + icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), GiosSensorEntityDescription( key=ATTR_NO2, name="NO2", + value=lambda sensors: sensors.no2.value if sensors.no2 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_NO2, + subkey="index", + name="NO2 index", + value=lambda sensors: sensors.no2.index if sensors.no2 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="no2_index", + ), GiosSensorEntityDescription( key=ATTR_O3, name="O3", + value=lambda sensors: sensors.o3.value if sensors.o3 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_O3, + subkey="index", + name="O3 index", + value=lambda sensors: sensors.o3.index if sensors.o3 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="o3_index", + ), GiosSensorEntityDescription( key=ATTR_PM10, name="PM10", + value=lambda sensors: sensors.pm10.value if sensors.pm10 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_PM10, + subkey="index", + name="PM10 index", + value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="pm10_index", + ), GiosSensorEntityDescription( key=ATTR_PM25, name="PM2.5", + value=lambda sensors: sensors.pm25.value if sensors.pm25 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_PM25, + subkey="index", + name="PM2.5 index", + value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="pm25_index", + ), GiosSensorEntityDescription( key=ATTR_SO2, name="SO2", + value=lambda sensors: sensors.so2.value if sensors.so2 else None, + suggested_display_precision=0, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), + GiosSensorEntityDescription( + key=ATTR_SO2, + subkey="index", + name="SO2 index", + value=lambda sensors: sensors.so2.index if sensors.so2 else None, + icon="mdi:molecule", + device_class=SensorDeviceClass.ENUM, + options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], + translation_key="so2_index", + ), ) @@ -140,15 +207,13 @@ async def async_setup_entry( ) entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - sensors: list[GiosSensor | GiosAqiSensor] = [] + sensors: list[GiosSensor] = [] for description in SENSOR_TYPES: if getattr(coordinator.data, description.key) is None: continue - if description.key == ATTR_AQI: - sensors.append(GiosAqiSensor(name, coordinator, description)) - else: - sensors.append(GiosSensor(name, coordinator, description)) + sensors.append(GiosSensor(name, coordinator, description)) + async_add_entities(sensors) @@ -174,45 +239,27 @@ class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity): name=name, configuration_url=URL.format(station_id=coordinator.gios.station_id), ) - self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" - self._attrs: dict[str, Any] = { - ATTR_STATION: self.coordinator.gios.station_name, - } + if description.subkey: + self._attr_unique_id = ( + f"{coordinator.gios.station_id}-{description.key}-{description.subkey}" + ) + else: + self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" self.entity_description = description - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - self._attrs[ATTR_NAME] = getattr( - self.coordinator.data, self.entity_description.key - ).name - self._attrs[ATTR_INDEX] = getattr( - self.coordinator.data, self.entity_description.key - ).index - return self._attrs - @property def native_value(self) -> StateType: """Return the state.""" - state = getattr(self.coordinator.data, self.entity_description.key).value - assert self.entity_description.value is not None - return cast(StateType, self.entity_description.value(state)) - - -class GiosAqiSensor(GiosSensor): - """Define an GIOS AQI sensor.""" - - @property - def native_value(self) -> StateType: - """Return the state.""" - return cast( - StateType, getattr(self.coordinator.data, self.entity_description.key).value - ) + return self.entity_description.value(self.coordinator.data) @property def available(self) -> bool: """Return if entity is available.""" available = super().available - return available and bool( - getattr(self.coordinator.data, self.entity_description.key) - ) + sensor_data = getattr(self.coordinator.data, self.entity_description.key) + + # Sometimes the API returns sensor data without indexes + if self.entity_description.subkey: + return available and bool(sensor_data.index) + + return available and bool(sensor_data) diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index a76bd3f612cd..53e7dd78a8f9 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -34,6 +34,56 @@ "good": "Good", "very_good": "Very good" } + }, + "no2_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "o3_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "pm10_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "pm25_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } + }, + "so2_index": { + "state": { + "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", + "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", + "sufficient": "[%key:component::gios::entity::sensor::aqi::state::sufficient%]", + "moderate": "[%key:component::gios::entity::sensor::aqi::state::moderate%]", + "good": "[%key:component::gios::entity::sensor::aqi::state::good%]", + "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" + } } } } diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index c5b19502a0ff..48f0e2384011 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -5,12 +5,7 @@ from unittest.mock import patch from gios import ApiError -from homeassistant.components.gios.const import ( - ATTR_INDEX, - ATTR_STATION, - ATTRIBUTION, - DOMAIN, -) +from homeassistant.components.gios.const import ATTRIBUTION, DOMAIN from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -42,16 +37,14 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_c6h6") assert state - assert state.state == "0" + assert state.state == "0.23789" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_c6h6") assert entry @@ -59,16 +52,14 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_co") assert state - assert state.state == "252" + assert state.state == "251.874" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_co") assert entry @@ -76,94 +67,173 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_no2") assert state - assert state.state == "7" + assert state.state == "7.13411" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.NITROGEN_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_no2") assert entry assert entry.unique_id == "123-no2" + state = hass.states.get("sensor.home_no2_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_no2_index") + assert entry + assert entry.unique_id == "123-no2-index" + state = hass.states.get("sensor.home_o3") assert state - assert state.state == "96" + assert state.state == "95.7768" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.OZONE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_o3") assert entry assert entry.unique_id == "123-o3" + state = hass.states.get("sensor.home_o3_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_o3_index") + assert entry + assert entry.unique_id == "123-o3-index" + state = hass.states.get("sensor.home_pm10") assert state - assert state.state == "17" + assert state.state == "16.8344" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM10 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm10") assert entry assert entry.unique_id == "123-pm10" + state = hass.states.get("sensor.home_pm10_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_pm10_index") + assert entry + assert entry.unique_id == "123-pm10-index" + state = hass.states.get("sensor.home_pm2_5") assert state assert state.state == "4" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PM25 assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "good" entry = registry.async_get("sensor.home_pm2_5") assert entry assert entry.unique_id == "123-pm25" + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_pm2_5_index") + assert entry + assert entry.unique_id == "123-pm25-index" + state = hass.states.get("sensor.home_so2") assert state - assert state.state == "4" + assert state.state == "4.35478" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.SULPHUR_DIOXIDE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - assert state.attributes.get(ATTR_INDEX) == "very_good" entry = registry.async_get("sensor.home_so2") assert entry assert entry.unique_id == "123-so2" + state = hass.states.get("sensor.home_so2_index") + assert state + assert state.state == "very_good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_OPTIONS) == [ + "very_bad", + "bad", + "sufficient", + "moderate", + "good", + "very_good", + ] + + entry = registry.async_get("sensor.home_so2_index") + assert entry + assert entry.unique_id == "123-so2-index" + state = hass.states.get("sensor.home_aqi") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None assert state.attributes.get(ATTR_OPTIONS) == [ @@ -182,13 +252,23 @@ async def test_sensor(hass: HomeAssistant) -> None: async def test_availability(hass: HomeAssistant) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" + indexes = json.loads(load_fixture("gios/indexes.json")) + sensors = json.loads(load_fixture("gios/sensors.json")) + await init_integration(hass) state = hass.states.get("sensor.home_pm2_5") assert state - assert state.state != STATE_UNAVAILABLE assert state.state == "4" + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == "good" + future = utcnow() + timedelta(minutes=60) with patch( "homeassistant.components.gios.Gios._get_all_sensors", @@ -201,10 +281,18 @@ async def test_availability(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNAVAILABLE + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == STATE_UNAVAILABLE + future = utcnow() + timedelta(minutes=120) with patch( "homeassistant.components.gios.Gios._get_all_sensors", - return_value=json.loads(load_fixture("gios/sensors.json")), + return_value=sensors, ), patch( "homeassistant.components.gios.Gios._get_indexes", return_value={}, @@ -214,161 +302,69 @@ async def test_availability(hass: HomeAssistant) -> None: state = hass.states.get("sensor.home_pm2_5") assert state - assert state.state != STATE_UNAVAILABLE assert state.state == "4" + # Indexes are empty so the state should be unavailable state = hass.states.get("sensor.home_aqi") assert state assert state.state == STATE_UNAVAILABLE + # Indexes are empty so the state should be unavailable + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=180) + with patch( + "homeassistant.components.gios.Gios._get_all_sensors", return_value=sensors + ), patch( + "homeassistant.components.gios.Gios._get_indexes", + return_value=indexes, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == "4" + + state = hass.states.get("sensor.home_pm2_5_index") + assert state + assert state.state == "good" + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == "good" + async def test_invalid_indexes(hass: HomeAssistant) -> None: """Test states of the sensor when API returns invalid indexes.""" await init_integration(hass, invalid_indexes=True) - registry = er.async_get(hass) - state = hass.states.get("sensor.home_c6h6") + state = hass.states.get("sensor.home_no2_index") assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_c6h6") - assert entry - assert entry.unique_id == "123-c6h6" - - state = hass.states.get("sensor.home_co") + state = hass.states.get("sensor.home_o3_index") assert state - assert state.state == "252" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_co") - assert entry - assert entry.unique_id == "123-co" - - state = hass.states.get("sensor.home_no2") + state = hass.states.get("sensor.home_pm10_index") assert state - assert state.state == "7" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_no2") - assert entry - assert entry.unique_id == "123-no2" - - state = hass.states.get("sensor.home_o3") + state = hass.states.get("sensor.home_pm2_5_index") assert state - assert state.state == "96" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None + assert state.state == STATE_UNAVAILABLE - entry = registry.async_get("sensor.home_o3") - assert entry - assert entry.unique_id == "123-o3" - - state = hass.states.get("sensor.home_pm10") + state = hass.states.get("sensor.home_so2_index") assert state - assert state.state == "17" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None - - entry = registry.async_get("sensor.home_pm10") - assert entry - assert entry.unique_id == "123-pm10" - - state = hass.states.get("sensor.home_pm2_5") - assert state - assert state.state == "4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None - - entry = registry.async_get("sensor.home_pm2_5") - assert entry - assert entry.unique_id == "123-pm25" - - state = hass.states.get("sensor.home_so2") - assert state - assert state.state == "4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_STATION) == "Test Name 1" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_INDEX) is None - - entry = registry.async_get("sensor.home_so2") - assert entry - assert entry.unique_id == "123-so2" + assert state.state == STATE_UNAVAILABLE state = hass.states.get("sensor.home_aqi") assert state is None -async def test_aqi_sensor_availability(hass: HomeAssistant) -> None: - """Ensure that we mark the AQI sensor unavailable correctly when indexes are invalid.""" - await init_integration(hass) - - state = hass.states.get("sensor.home_aqi") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "good" - - future = utcnow() + timedelta(minutes=60) - with patch( - "homeassistant.components.gios.Gios._get_all_sensors", - return_value=json.loads(load_fixture("gios/sensors.json")), - ), patch( - "homeassistant.components.gios.Gios._get_indexes", - return_value={}, - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("sensor.home_aqi") - assert state - assert state.state == STATE_UNAVAILABLE - - async def test_unique_id_migration(hass: HomeAssistant) -> None: """Test states of the unique_id migration.""" registry = er.async_get(hass) From c0387a655c888175dd09225ade78f0b13db98f3d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Mar 2023 08:39:34 +0200 Subject: [PATCH 0847/1058] Turn AVM FRITZ!Box Tools binary sensors into coordinator entities (#89955) make binary sensors coordinator entities --- .../components/fritz/binary_sensor.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 918a114fdf20..228b7d5935cb 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -15,14 +15,21 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import AvmWrapper, ConnectionInfo, FritzBoxBaseEntity +from .common import ( + AvmWrapper, + ConnectionInfo, + FritzBoxBaseCoordinatorEntity, + FritzEntityDescription, +) from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @dataclass -class FritzBinarySensorEntityDescription(BinarySensorEntityDescription): +class FritzBinarySensorEntityDescription( + BinarySensorEntityDescription, FritzEntityDescription +): """Describes Fritz sensor entity.""" is_suitable: Callable[[ConnectionInfo], bool] = lambda info: info.wan_enabled @@ -34,12 +41,14 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = ( name="Connection", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status, _: bool(status.is_connected), ), FritzBinarySensorEntityDescription( key="is_linked", name="Link", device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda status, _: bool(status.is_linked), ), ) @@ -62,25 +71,16 @@ async def async_setup_entry( async_add_entities(entities, True) -class FritzBoxBinarySensor(FritzBoxBaseEntity, BinarySensorEntity): +class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity): """Define FRITZ!Box connectivity class.""" - def __init__( - self, - avm_wrapper: AvmWrapper, - device_friendly_name: str, - description: BinarySensorEntityDescription, - ) -> None: - """Init FRITZ!Box connectivity class.""" - self.entity_description = description - self._attr_name = f"{device_friendly_name} {description.name}" - self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" - super().__init__(avm_wrapper, device_friendly_name) + entity_description: FritzBinarySensorEntityDescription - def update(self) -> None: - """Update data.""" - _LOGGER.debug("Updating FRITZ!Box binary sensors") - if self.entity_description.key == "is_connected": - self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_connected) - elif self.entity_description.key == "is_linked": - self._attr_is_on = bool(self._avm_wrapper.fritz_status.is_linked) + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if isinstance( + state := self.coordinator.data.get(self.entity_description.key), bool + ): + return state + return None From b399e5c8b7dad57beb2e4faf8676e61dd19ab1fe Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Mar 2023 02:45:10 -0400 Subject: [PATCH 0848/1058] Handle uncaught exceptions during update in Aladdin_connect (#89889) * Handle uncaught errors during update * Remove unnecssary patch * Update tests/components/aladdin_connect/test_cover.py Co-authored-by: Franck Nijhof * Update tests/components/aladdin_connect/test_cover.py Co-authored-by: Franck Nijhof * Remove unasserted statement * Blocking is True - one more --------- Co-authored-by: Franck Nijhof --- .../components/aladdin_connect/cover.py | 17 ++- .../components/aladdin_connect/test_cover.py | 111 ++++++++++-------- 2 files changed, 74 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 5837920560c7..2cf526e5626b 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from AIOAladdinConnect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient, session_manager from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -46,7 +46,7 @@ class AladdinDevice(CoverEntity): ) -> None: """Initialize the Aladdin Connect cover.""" self._acc = acc - + self._entry_id = entry.entry_id self._device_id = device["device_id"] self._number = device["door_number"] self._name = device["name"] @@ -85,7 +85,18 @@ class AladdinDevice(CoverEntity): async def async_update(self) -> None: """Update status of cover.""" - await self._acc.get_doors(self._serial) + try: + await self._acc.get_doors(self._serial) + self._attr_available = True + + except session_manager.ConnectionError: + self._attr_available = False + + except session_manager.InvalidPasswordError: + self._attr_available = False + await self.hass.async_create_task( + self.hass.config_entries.async_reload(self._entry_id) + ) @property def is_closed(self) -> bool | None: diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index e63b50607c4f..eb617b959a5c 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -1,6 +1,8 @@ """Test the Aladdin Connect Cover.""" from unittest.mock import AsyncMock, MagicMock, patch +from AIOAladdinConnect import session_manager + from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL from homeassistant.components.cover import DOMAIN as COVER_DOMAIN @@ -13,6 +15,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -97,8 +100,10 @@ async def test_cover_operation( assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_OPEN) mock_aladdinconnect_api.get_door_status.return_value = STATE_OPEN + with patch( "homeassistant.components.aladdin_connect.AladdinConnectClient", return_value=mock_aladdinconnect_api, @@ -116,27 +121,22 @@ async def test_cover_operation( {ATTR_ENTITY_ID: "cover.home"}, blocking=True, ) - await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_OPEN mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_CLOSED @@ -145,15 +145,11 @@ async def test_cover_operation( ) mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSING - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_CLOSING mock_aladdinconnect_api.async_get_door_status = AsyncMock( @@ -161,34 +157,47 @@ async def test_cover_operation( ) mock_aladdinconnect_api.get_door_status.return_value = STATE_OPENING - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_OPENING mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=None) mock_aladdinconnect_api.get_door_status.return_value = None - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_UNKNOWN + + mock_aladdinconnect_api.get_doors.side_effect = session_manager.ConnectionError + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + assert hass.states.get("cover.home").state == STATE_UNAVAILABLE + + mock_aladdinconnect_api.get_doors.side_effect = session_manager.InvalidPasswordError + mock_aladdinconnect_api.login.return_value = False + mock_aladdinconnect_api.login.side_effect = session_manager.InvalidPasswordError + + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + assert hass.states.get("cover.home").state == STATE_UNAVAILABLE From 8807878529660a0dad04c20f97007182c63e3666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cosmin=20Lu=C8=9B=C4=83?= Date: Tue, 28 Mar 2023 09:46:16 +0300 Subject: [PATCH 0849/1058] Add Mikrotik WifiWave2 (#89711) * Add support for wifiwave2 * Add test for wifiwave2 --- homeassistant/components/mikrotik/const.py | 5 +++ homeassistant/components/mikrotik/hub.py | 7 ++++ tests/components/mikrotik/__init__.py | 32 +++++++++++++++++++ .../mikrotik/test_device_tracker.py | 23 +++++++++++++ 4 files changed, 67 insertions(+) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index 911d348365e0..4354b9b06bda 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -24,8 +24,11 @@ ARP: Final = "arp" CAPSMAN: Final = "capsman" DHCP: Final = "dhcp" WIRELESS: Final = "wireless" +WIFIWAVE2: Final = "wifiwave2" IS_WIRELESS: Final = "is_wireless" IS_CAPSMAN: Final = "is_capsman" +IS_WIFIWAVE2: Final = "is_wifiwave2" + MIKROTIK_SERVICES: Final = { ARP: "/ip/arp/getall", @@ -34,8 +37,10 @@ MIKROTIK_SERVICES: Final = { IDENTITY: "/system/identity/getall", INFO: "/system/routerboard/getall", WIRELESS: "/interface/wireless/registration-table/getall", + WIFIWAVE2: "/interface/wifiwave2/registration-table/print", IS_WIRELESS: "/interface/wireless/print", IS_CAPSMAN: "/caps-man/interface/print", + IS_WIFIWAVE2: "/interface/wifiwave2/print", } diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 26a589486206..9e0a610c7701 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -31,9 +31,11 @@ from .const import ( IDENTITY, INFO, IS_CAPSMAN, + IS_WIFIWAVE2, IS_WIRELESS, MIKROTIK_SERVICES, NAME, + WIFIWAVE2, WIRELESS, ) from .device import Device @@ -57,6 +59,7 @@ class MikrotikData: self.devices: dict[str, Device] = {} self.support_capsman: bool = False self.support_wireless: bool = False + self.support_wifiwave2: bool = False self.hostname: str = "" self.model: str = "" self.firmware: str = "" @@ -97,6 +100,7 @@ class MikrotikData: self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) + self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -121,6 +125,9 @@ class MikrotikData: elif self.support_wireless: _LOGGER.debug("Hub supports wireless Interface") device_list = wireless_devices = self.get_list_from_interface(WIRELESS) + elif self.support_wifiwave2: + _LOGGER.debug("Hub supports wifiwave2 Interface") + device_list = wireless_devices = self.get_list_from_interface(WIFIWAVE2) if not device_list or self.force_dhcp: device_list = self.all_devices diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index b7f79f8ea511..158f86fe452d 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -62,6 +62,14 @@ DEVICE_3_DHCP_NUMERIC_NAME = { "host-name": 123, "comment": "Mobile", } +DEVICE_4_DHCP = { + ".id": "*F7", + "address": "0.0.0.4", + "mac-address": "00:00:00:00:00:04", + "active-address": "0.0.0.4", + "host-name": "Device_4", + "comment": "Wifiwave2 device", +} DEVICE_1_WIRELESS = { ".id": "*264", "interface": "wlan1", @@ -109,9 +117,27 @@ DEVICE_3_WIRELESS = { "mac-address": "00:00:00:00:00:03", "last-ip": "0.0.0.3", } + +DEVICE_4_WIFIWAVE2 = { + ".id": "*F7", + "interface": "wifi1", + "ssid": "test-ssid", + "mac-address": "00:00:00:00:00:04", + "uptime": "2d15h28m27s", + "signal": -47, + "tx-rate": 54000000, + "rx-rate": 54000000, + "packets": "17748,18516", + "bytes": "1851474,2037295", + "tx-bits-per-second": 0, + "rx-bits-per-second": 0, + "authorized": True, +} + DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] WIRELESS_DATA = [DEVICE_1_WIRELESS] +WIFIWAVE2_DATA = [DEVICE_4_WIFIWAVE2] ARP_DATA = [ { @@ -144,16 +170,22 @@ ARP_DATA = [ async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: """Set up Mikrotik integration successfully.""" support_wireless: bool = kwargs.get("support_wireless", True) + support_wifiwave2: bool = kwargs.get("support_wifiwave2", False) dhcp_data: list[dict[str, Any]] = kwargs.get("dhcp_data", DHCP_DATA) wireless_data: list[dict[str, Any]] = kwargs.get("wireless_data", WIRELESS_DATA) + wifiwave2_data: list[dict[str, Any]] = kwargs.get("wifiwave2_data", WIFIWAVE2_DATA) def mock_command(self, cmd: str, params: dict[str, Any] | None = None) -> Any: if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: return support_wireless + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFIWAVE2]: + return support_wifiwave2 if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: return dhcp_data if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: return wireless_data + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIFIWAVE2]: + return wifiwave2_data if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: return ARP_DATA return {} diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index bd921320d791..323c958eb22d 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -18,6 +18,8 @@ from . import ( DEVICE_2_WIRELESS, DEVICE_3_DHCP_NUMERIC_NAME, DEVICE_3_WIRELESS, + DEVICE_4_DHCP, + DEVICE_4_WIFIWAVE2, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, @@ -39,6 +41,7 @@ def mock_device_registry_devices(hass: HomeAssistant) -> None: "00:00:00:00:00:01", "00:00:00:00:00:02", "00:00:00:00:00:03", + "00:00:00:00:00:04", ) ): dev_reg.async_get_or_create( @@ -184,6 +187,26 @@ async def test_device_trackers_numerical_name( assert device_3.attributes["host_name"] == "123" +async def test_hub_wifiwave2(hass: HomeAssistant, mock_device_registry_devices) -> None: + """Test device_trackers created when hub supports wifiwave2.""" + + await setup_mikrotik_entry( + hass, + dhcp_data=[DEVICE_4_DHCP], + wifiwave2_data=[DEVICE_4_WIFIWAVE2], + support_wireless=False, + support_wifiwave2=True, + ) + + device_4 = hass.states.get("device_tracker.device_4") + assert device_4 + assert device_4.state == "home" + assert device_4.attributes["friendly_name"] == "Device_4" + assert device_4.attributes["ip"] == "0.0.0.4" + assert device_4.attributes["mac"] == "00:00:00:00:00:04" + assert device_4.attributes["host_name"] == "Device_4" + + async def test_restoring_devices(hass: HomeAssistant) -> None: """Test restoring existing device_tracker entities if not detected on startup.""" config_entry = MockConfigEntry( From db6f0827aa9b9f9c31dd49f8c2ff552a8c9f7093 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Tue, 28 Mar 2023 14:47:45 +0800 Subject: [PATCH 0850/1058] Allow reloading iZone config entry (#89572) * Allow reloading of iZone config entries --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/izone/__init__.py | 28 +++++++++++++-------- homeassistant/components/izone/climate.py | 4 ++- homeassistant/components/izone/discovery.py | 17 +++++++------ 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 3f2565bd8f4f..fd8d27ac4222 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -3,7 +3,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EXCLUDE, Platform +from homeassistant.const import CONF_EXCLUDE, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -29,29 +29,35 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the iZone component config.""" - if not (conf := config.get(IZONE)): - return True - hass.data[DATA_CONFIG] = conf + # Check for manually added config, this may exclude some devices + if conf := config.get(IZONE): + hass.data[DATA_CONFIG] = conf - # Explicitly added in the config file, create a config entry. - hass.async_create_task( - hass.config_entries.flow.async_init( - IZONE, context={"source": config_entries.SOURCE_IMPORT} + # Explicitly added in the config file, create a config entry. + hass.async_create_task( + hass.config_entries.flow.async_init( + IZONE, context={"source": config_entries.SOURCE_IMPORT} + ) ) - ) + + # Start the discovery service + await async_start_discovery_service(hass) + + async def shutdown_event(event): + await async_stop_discovery_service(hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - await async_start_discovery_service(hass) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the config entry and stop discovery process.""" - await async_stop_discovery_service(hass) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 3e19afcca26a..e5a45dbc5e6b 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -95,7 +95,9 @@ async def async_setup_entry( init_controller(controller) # connect to register any further components - async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + config.async_on_unload( + async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + ) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index eb6e7d4a190e..a170ed30a749 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -1,7 +1,8 @@ """Internal discovery service for iZone AC.""" +import logging + import pizone -from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -15,15 +16,17 @@ from .const import ( DISPATCH_ZONE_UPDATE, ) +_LOGGER = logging.getLogger(__name__) + class DiscoveryService(pizone.Listener): """Discovery data and interfacing with pizone library.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialise discovery service.""" super().__init__() self.hass = hass - self.pi_disco = None + self.pi_disco: pizone.DiscoveryService | None = None # Listener interface def controller_discovered(self, ctrl: pizone.Controller) -> None: @@ -52,6 +55,7 @@ async def async_start_discovery_service(hass: HomeAssistant): if disco := hass.data.get(DATA_DISCOVERY_SERVICE): # Already started return disco + _LOGGER.debug("Starting iZone Discovery Service") # discovery local services disco = DiscoveryService(hass) @@ -62,11 +66,6 @@ async def async_start_discovery_service(hass: HomeAssistant): disco.pi_disco = pizone.discovery(disco, session=session) await disco.pi_disco.start_discovery() - async def shutdown_event(event): - await async_stop_discovery_service(hass) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) - return disco @@ -77,3 +76,5 @@ async def async_stop_discovery_service(hass: HomeAssistant): await disco.pi_disco.close() del hass.data[DATA_DISCOVERY_SERVICE] + + _LOGGER.debug("Stopped iZone Discovery Service") From 38f3b9f165f3ccd0366ee9a4ba59499b9cb43c44 Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Tue, 28 Mar 2023 02:48:32 -0400 Subject: [PATCH 0851/1058] Add SharkIQ EU region support (#89349) * SharkIQ Dep & Codeowner Update * Update code owners * Add EU Region Support * Update Config Flow Tests * Standardize Region Comparison Strings * Add Translation Support to Region Selector * Fix Validation Tests --- homeassistant/components/sharkiq/__init__.py | 5 +- .../components/sharkiq/config_flow.py | 49 ++++++++++++++++--- homeassistant/components/sharkiq/const.py | 5 ++ homeassistant/components/sharkiq/strings.json | 19 ++++++- tests/components/sharkiq/const.py | 9 +++- tests/components/sharkiq/test_config_flow.py | 11 +++-- 6 files changed, 80 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index 0c4f7bb0bfc0..738dd595a5a7 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -13,11 +13,11 @@ from sharkiq import ( from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import API_TIMEOUT, DOMAIN, LOGGER, PLATFORMS +from .const import API_TIMEOUT, DOMAIN, LOGGER, PLATFORMS, SHARKIQ_REGION_EUROPE from .update_coordinator import SharkIqUpdateCoordinator @@ -47,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b username=config_entry.data[CONF_USERNAME], password=config_entry.data[CONF_PASSWORD], websession=async_get_clientsession(hass), + europe=(config_entry.data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) try: diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index b0aae5259dd9..57de36ce4159 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -11,14 +11,31 @@ from sharkiq import SharkIqAuthError, get_ayla_api import voluptuous as vol from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, LOGGER +from .const import ( + DOMAIN, + LOGGER, + SHARKIQ_REGION_DEFAULT, + SHARKIQ_REGION_EUROPE, + SHARKIQ_REGION_OPTIONS, +) SHARKIQ_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required( + CONF_REGION, default=SHARKIQ_REGION_DEFAULT + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=SHARKIQ_REGION_OPTIONS, translation_key="region" + ), + ), + } ) @@ -30,16 +47,29 @@ async def _validate_input( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], websession=async_get_clientsession(hass), + europe=(data[CONF_REGION] == SHARKIQ_REGION_EUROPE), ) try: async with async_timeout.timeout(10): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() - except (asyncio.TimeoutError, aiohttp.ClientError) as errors: - raise CannotConnect from errors + except (asyncio.TimeoutError, aiohttp.ClientError, TypeError) as error: + LOGGER.error(error) + raise CannotConnect( + "Unable to connect to SharkIQ services. Check your region settings." + ) from error except SharkIqAuthError as error: - raise InvalidAuth from error + LOGGER.error(error) + raise InvalidAuth( + "Username or password incorrect. Please check your credentials." + ) from error + except Exception as error: + LOGGER.exception("Unexpected exception") + LOGGER.error(error) + raise UnknownAuth( + "An unknown error occurred. Check your region settings and open an issue on Github if the issue persists." + ) from error # Return info that you want to store in the config entry. return {"title": data[CONF_USERNAME]} @@ -64,8 +94,7 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") + except UnknownAuth: # pylint: disable=broad-except errors["base"] = "unknown" return info, errors @@ -114,3 +143,7 @@ class CannotConnect(exceptions.HomeAssistantError): class InvalidAuth(exceptions.HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class UnknownAuth(exceptions.HomeAssistantError): + """Error to indicate there is an uncaught auth error.""" diff --git a/homeassistant/components/sharkiq/const.py b/homeassistant/components/sharkiq/const.py index fb683bb525a8..b12a86dc2407 100644 --- a/homeassistant/components/sharkiq/const.py +++ b/homeassistant/components/sharkiq/const.py @@ -11,3 +11,8 @@ PLATFORMS = [Platform.VACUUM] DOMAIN = "sharkiq" SHARK = "Shark" UPDATE_INTERVAL = timedelta(seconds=30) + +SHARKIQ_REGION_EUROPE = "europe" +SHARKIQ_REGION_ELSEWHERE = "elsewhere" +SHARKIQ_REGION_DEFAULT = SHARKIQ_REGION_ELSEWHERE +SHARKIQ_REGION_OPTIONS = [SHARKIQ_REGION_EUROPE, SHARKIQ_REGION_ELSEWHERE] diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index bc920ac7c7eb..23f949be4cc6 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -1,16 +1,23 @@ { "config": { + "flow_title": "Add Shark IQ Account", "step": { "user": { + "description": "Sign into your Shark Clean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" + }, + "data_description": { + "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." } }, "reauth": { "data": { "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "region": "Region" } } }, @@ -25,5 +32,13 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "selector": { + "region": { + "options": { + "europe": "Europe", + "elsewhere": "Everywhere Else" + } + } } } diff --git a/tests/components/sharkiq/const.py b/tests/components/sharkiq/const.py index 305d12ddfa78..8ec7d424ffa1 100644 --- a/tests/components/sharkiq/const.py +++ b/tests/components/sharkiq/const.py @@ -1,6 +1,6 @@ """Constants used in shark iq tests.""" -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME # Dummy device dict of the form returned by AylaApi.list_devices() SHARK_DEVICE_DICT = { @@ -69,6 +69,11 @@ SHARK_PROPERTIES_DICT = { TEST_USERNAME = "test-username" TEST_PASSWORD = "test-password" +TEST_REGION = "elsewhere" UNIQUE_ID = "foo@bar.com" -CONFIG = {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD} +CONFIG = { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_REGION: TEST_REGION, +} ENTRY_ID = "0123456789abcdef0123456789abcdef" diff --git a/tests/components/sharkiq/test_config_flow.py b/tests/components/sharkiq/test_config_flow.py index f611d8e6d8e1..c7a0603f8651 100644 --- a/tests/components/sharkiq/test_config_flow.py +++ b/tests/components/sharkiq/test_config_flow.py @@ -3,13 +3,13 @@ from unittest.mock import patch import aiohttp import pytest -from sharkiq import AylaApi, SharkIqAuthError +from sharkiq import AylaApi, SharkIqAuthError, SharkIqError from homeassistant import config_entries from homeassistant.components.sharkiq.const import DOMAIN from homeassistant.core import HomeAssistant -from .const import CONFIG, TEST_PASSWORD, TEST_USERNAME, UNIQUE_ID +from .const import CONFIG, TEST_PASSWORD, TEST_REGION, TEST_USERNAME, UNIQUE_ID from tests.common import MockConfigEntry @@ -37,6 +37,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "username": TEST_USERNAME, "password": TEST_PASSWORD, + "region": TEST_REGION, } await hass.async_block_till_done() mock_setup_entry.assert_called_once() @@ -47,7 +48,8 @@ async def test_form(hass: HomeAssistant) -> None: [ (SharkIqAuthError, "invalid_auth"), (aiohttp.ClientError, "cannot_connect"), - (TypeError, "unknown"), + (TypeError, "cannot_connect"), + (SharkIqError, "unknown"), ], ) async def test_form_error(hass: HomeAssistant, exc: Exception, base_error: str) -> None: @@ -87,7 +89,8 @@ async def test_reauth_success(hass: HomeAssistant) -> None: [ (SharkIqAuthError, "form", "errors", "invalid_auth"), (aiohttp.ClientError, "abort", "reason", "cannot_connect"), - (TypeError, "abort", "reason", "unknown"), + (TypeError, "abort", "reason", "cannot_connect"), + (SharkIqError, "abort", "reason", "unknown"), ], ) async def test_reauth( From bfb5daa31c23afd788a9430dfec040bf96e4b90d Mon Sep 17 00:00:00 2001 From: gjong Date: Tue, 28 Mar 2023 08:49:31 +0200 Subject: [PATCH 0852/1058] Add phase information to YouLess (#89255) --- homeassistant/components/youless/sensor.py | 99 +++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py index b9120f433dec..057533081e65 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -10,7 +10,14 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, UnitOfEnergy, UnitOfPower, UnitOfVolume +from homeassistant.const import ( + CONF_DEVICE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfVolume, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -47,6 +54,15 @@ async def async_setup_entry( DeliveryMeterSensor(coordinator, device, "high"), ExtraMeterSensor(coordinator, device, "total"), ExtraMeterPowerSensor(coordinator, device, "usage"), + PhasePowerSensor(coordinator, device, 1), + PhaseVoltageSensor(coordinator, device, 1), + PhaseCurrentSensor(coordinator, device, 1), + PhasePowerSensor(coordinator, device, 2), + PhaseVoltageSensor(coordinator, device, 2), + PhaseCurrentSensor(coordinator, device, 2), + PhasePowerSensor(coordinator, device, 3), + PhaseVoltageSensor(coordinator, device, 3), + PhaseCurrentSensor(coordinator, device, 3), ] ) @@ -193,6 +209,87 @@ class EnergyMeterSensor(YoulessBaseSensor): return getattr(self.coordinator.data.power_meter, f"_{self._type}", None) +class PhasePowerSensor(YoulessBaseSensor): + """The current power usage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_device_class = SensorDeviceClass.POWER + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the power phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_power" + ) + self._attr_name = f"Phase {phase} power" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.power + + +class PhaseVoltageSensor(YoulessBaseSensor): + """The current voltage of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the voltage phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_voltage" + ) + self._attr_name = f"Phase {phase} voltage" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase voltage.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.voltage + + +class PhaseCurrentSensor(YoulessBaseSensor): + """The current current of a single phase.""" + + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_device_class = SensorDeviceClass.CURRENT + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__( + self, coordinator: DataUpdateCoordinator[YoulessAPI], device: str, phase: int + ) -> None: + """Initialize the current phase sensor.""" + super().__init__( + coordinator, device, "power", "Energy usage", f"phase_{phase}_current" + ) + self._attr_name = f"Phase {phase} current" + self._phase = phase + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor value from the coordinator for phase current.""" + phase_sensor = getattr(self.coordinator.data, f"phase{self._phase}", None) + if phase_sensor is None: + return None + + return phase_sensor.current + + class ExtraMeterSensor(YoulessBaseSensor): """The Youless extra meter value sensor (s0).""" From 586471b5a9eaffa99b94c577a07bc7a0add8d046 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 09:11:13 +0200 Subject: [PATCH 0853/1058] Improve threshold binary sensor (#88978) Improve threshold sensor --- .../components/threshold/binary_sensor.py | 58 ++++++++++------ .../threshold/test_binary_sensor.py | 68 +++++++++---------- 2 files changed, 71 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 0badf7eb41fb..538655ec0ce3 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -114,6 +114,15 @@ async def async_setup_platform( ) +def _threshold_type(lower: float | None, upper: float | None) -> str: + """Return the type of threshold this sensor represents.""" + if lower is not None and upper is not None: + return TYPE_RANGE + if lower is not None: + return TYPE_LOWER + return TYPE_UPPER + + class ThresholdSensor(BinarySensorEntity): """Representation of a Threshold sensor.""" @@ -134,8 +143,11 @@ class ThresholdSensor(BinarySensorEntity): self._attr_unique_id = unique_id self._entity_id = entity_id self._name = name - self._threshold_lower = lower - self._threshold_upper = upper + if lower is not None: + self._threshold_lower = lower + if upper is not None: + self._threshold_upper = upper + self.threshold_type = _threshold_type(lower, upper) self._hysteresis: float = hysteresis self._device_class = device_class self._state_position = POSITION_UNKNOWN @@ -187,26 +199,17 @@ class ThresholdSensor(BinarySensorEntity): """Return the sensor class of the sensor.""" return self._device_class - @property - def threshold_type(self) -> str: - """Return the type of threshold this sensor represents.""" - if self._threshold_lower is not None and self._threshold_upper is not None: - return TYPE_RANGE - if self._threshold_lower is not None: - return TYPE_LOWER - return TYPE_UPPER - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, ATTR_HYSTERESIS: self._hysteresis, - ATTR_LOWER: self._threshold_lower, + ATTR_LOWER: getattr(self, "_threshold_lower", None), ATTR_POSITION: self._state_position, ATTR_SENSOR_VALUE: self.sensor_value, ATTR_TYPE: self.threshold_type, - ATTR_UPPER: self._threshold_upper, + ATTR_UPPER: getattr(self, "_threshold_upper", None), } @callback @@ -223,30 +226,42 @@ class ThresholdSensor(BinarySensorEntity): if self.sensor_value is None: self._state_position = POSITION_UNKNOWN - self._state = False + self._state = None return - if self.threshold_type == TYPE_LOWER and self._threshold_lower is not None: + if self.threshold_type == TYPE_LOWER: + if self._state is None: + self._state = False + self._state_position = POSITION_ABOVE + if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = True elif above(self.sensor_value, self._threshold_lower): self._state_position = POSITION_ABOVE self._state = False + return + + if self.threshold_type == TYPE_UPPER: + assert self._threshold_upper is not None + + if self._state is None: + self._state = False + self._state_position = POSITION_BELOW - if self.threshold_type == TYPE_UPPER and self._threshold_upper is not None: if above(self.sensor_value, self._threshold_upper): self._state_position = POSITION_ABOVE self._state = True elif below(self.sensor_value, self._threshold_upper): self._state_position = POSITION_BELOW self._state = False + return + + if self.threshold_type == TYPE_RANGE: + if self._state is None: + self._state = True + self._state_position = POSITION_IN_RANGE - if ( - self.threshold_type == TYPE_RANGE - and self._threshold_lower is not None - and self._threshold_upper is not None - ): if below(self.sensor_value, self._threshold_lower): self._state_position = POSITION_BELOW self._state = False @@ -258,3 +273,4 @@ class ThresholdSensor(BinarySensorEntity): ): self._state_position = POSITION_IN_RANGE self._state = True + return diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index eed3a8a40e01..9e11195d878f 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -29,8 +29,8 @@ async def test_sensor_upper(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "below" + assert state.state == "off" hass.states.async_set( "sensor.test_monitored", @@ -63,12 +63,12 @@ async def test_sensor_upper(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" + assert state.attributes["position"] == "below" assert state.state == "off" @@ -89,8 +89,8 @@ async def test_sensor_lower(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "above" + assert state.state == "off" hass.states.async_set("sensor.test_monitored", 16) await hass.async_block_till_done() @@ -117,12 +117,12 @@ async def test_sensor_lower(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 15) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" + assert state.attributes["position"] == "above" assert state.state == "off" @@ -144,15 +144,15 @@ async def test_sensor_upper_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 17.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "below" + assert state.state == "off" # Set the monitored sensor's state to the threshold - hysteresis hass.states.async_set("sensor.test_monitored", 12.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "below" + assert state.state == "off" hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() @@ -192,7 +192,7 @@ async def test_sensor_upper_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() @@ -219,15 +219,15 @@ async def test_sensor_lower_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 17.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "above" + assert state.state == "off" # Set the monitored sensor's state to the threshold - hysteresis hass.states.async_set("sensor.test_monitored", 12.5) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "above" + assert state.state == "off" hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() @@ -267,7 +267,7 @@ async def test_sensor_lower_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() @@ -294,15 +294,15 @@ async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 10) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the upper threshold hass.states.async_set("sensor.test_monitored", 20) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" hass.states.async_set( "sensor.test_monitored", @@ -336,7 +336,7 @@ async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 21) await hass.async_block_till_done() @@ -364,29 +364,29 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 8) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the lower threshold + hysteresis hass.states.async_set("sensor.test_monitored", 12) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the upper threshold + hysteresis hass.states.async_set("sensor.test_monitored", 22) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" # Set the monitored sensor's state to the upper threshold - hysteresis hass.states.async_set("sensor.test_monitored", 18) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.attributes["position"] == "in_range" + assert state.state == "on" hass.states.async_set( "sensor.test_monitored", @@ -460,7 +460,7 @@ async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", 17) await hass.async_block_till_done() @@ -507,13 +507,13 @@ async def test_sensor_in_range_unknown_state( await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "off" + assert state.state == "unknown" assert "State is not numerical" not in caplog.text From dc05272120af9560b120cf41567c3f5658724c8d Mon Sep 17 00:00:00 2001 From: stickpin <630000+stickpin@users.noreply.github.com> Date: Tue, 28 Mar 2023 09:14:19 +0200 Subject: [PATCH 0854/1058] Display only supported Home Connect appliance programs (#88801) Show only supported device programs --- homeassistant/components/home_connect/api.py | 115 ++----------------- 1 file changed, 7 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index f50ab7115509..85d8abd1cba2 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -145,11 +145,14 @@ class HomeConnectDevice: class DeviceWithPrograms(HomeConnectDevice): """Device with programs.""" - PROGRAMS: list[dict[str, str]] = [] - def get_programs_available(self): """Get the available programs.""" - return self.PROGRAMS + try: + programs_available = self.appliance.get_programs_available() + except (HomeConnectError, ValueError): + _LOGGER.debug("Unable to fetch available programs. Probably offline") + programs_available = None + return programs_available def get_program_switches(self): """Get a dictionary with info about program switches. @@ -157,7 +160,7 @@ class DeviceWithPrograms(HomeConnectDevice): There will be one switch for each program. """ programs = self.get_programs_available() - return [{ATTR_DEVICE: self, "program_name": p["name"]} for p in programs] + return [{ATTR_DEVICE: self, "program_name": p} for p in programs] def get_program_sensors(self): """Get a dictionary with info about program sensors. @@ -265,27 +268,6 @@ class Dryer( ): """Dryer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Dryer.Program.Cotton"}, - {"name": "LaundryCare.Dryer.Program.Synthetic"}, - {"name": "LaundryCare.Dryer.Program.Mix"}, - {"name": "LaundryCare.Dryer.Program.Blankets"}, - {"name": "LaundryCare.Dryer.Program.BusinessShirts"}, - {"name": "LaundryCare.Dryer.Program.DownFeathers"}, - {"name": "LaundryCare.Dryer.Program.Hygiene"}, - {"name": "LaundryCare.Dryer.Program.Jeans"}, - {"name": "LaundryCare.Dryer.Program.Outdoor"}, - {"name": "LaundryCare.Dryer.Program.SyntheticRefresh"}, - {"name": "LaundryCare.Dryer.Program.Towels"}, - {"name": "LaundryCare.Dryer.Program.Delicates"}, - {"name": "LaundryCare.Dryer.Program.Super40"}, - {"name": "LaundryCare.Dryer.Program.Shirts15"}, - {"name": "LaundryCare.Dryer.Program.Pillow"}, - {"name": "LaundryCare.Dryer.Program.AntiShrink"}, - {"name": "LaundryCare.Dryer.Program.TimeCold"}, - {"name": "LaundryCare.Dryer.Program.TimeWarm"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -311,32 +293,6 @@ class Dishwasher( ): """Dishwasher class.""" - PROGRAMS = [ - {"name": "Dishcare.Dishwasher.Program.Auto1"}, - {"name": "Dishcare.Dishwasher.Program.Auto2"}, - {"name": "Dishcare.Dishwasher.Program.Auto3"}, - {"name": "Dishcare.Dishwasher.Program.Eco50"}, - {"name": "Dishcare.Dishwasher.Program.Quick45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv70"}, - {"name": "Dishcare.Dishwasher.Program.Normal65"}, - {"name": "Dishcare.Dishwasher.Program.Glas40"}, - {"name": "Dishcare.Dishwasher.Program.GlassCare"}, - {"name": "Dishcare.Dishwasher.Program.PreRinse"}, - {"name": "Dishcare.Dishwasher.Program.NightWash"}, - {"name": "Dishcare.Dishwasher.Program.Quick65"}, - {"name": "Dishcare.Dishwasher.Program.Normal45"}, - {"name": "Dishcare.Dishwasher.Program.Intensiv45"}, - {"name": "Dishcare.Dishwasher.Program.AutoHalfLoad"}, - {"name": "Dishcare.Dishwasher.Program.IntensivPower"}, - {"name": "Dishcare.Dishwasher.Program.MagicDaily"}, - {"name": "Dishcare.Dishwasher.Program.Super60"}, - {"name": "Dishcare.Dishwasher.Program.Kurz60"}, - {"name": "Dishcare.Dishwasher.Program.ExpressSparkle65"}, - {"name": "Dishcare.Dishwasher.Program.MachineCare"}, - {"name": "Dishcare.Dishwasher.Program.SteamFresh"}, - {"name": "Dishcare.Dishwasher.Program.MaximumCleaning"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -361,14 +317,6 @@ class Oven( ): """Oven class.""" - PROGRAMS = [ - {"name": "Cooking.Oven.Program.HeatingMode.PreHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.HotAir"}, - {"name": "Cooking.Oven.Program.HeatingMode.TopBottomHeating"}, - {"name": "Cooking.Oven.Program.HeatingMode.PizzaSetting"}, - {"name": "Cooking.Oven.Program.Microwave.600Watt"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -395,30 +343,6 @@ class Washer( ): """Washer class.""" - PROGRAMS = [ - {"name": "LaundryCare.Washer.Program.Cotton"}, - {"name": "LaundryCare.Washer.Program.Cotton.CottonEco"}, - {"name": "LaundryCare.Washer.Program.EasyCare"}, - {"name": "LaundryCare.Washer.Program.Mix"}, - {"name": "LaundryCare.Washer.Program.DelicatesSilk"}, - {"name": "LaundryCare.Washer.Program.Wool"}, - {"name": "LaundryCare.Washer.Program.Sensitive"}, - {"name": "LaundryCare.Washer.Program.Auto30"}, - {"name": "LaundryCare.Washer.Program.Auto40"}, - {"name": "LaundryCare.Washer.Program.Auto60"}, - {"name": "LaundryCare.Washer.Program.Chiffon"}, - {"name": "LaundryCare.Washer.Program.Curtains"}, - {"name": "LaundryCare.Washer.Program.DarkWash"}, - {"name": "LaundryCare.Washer.Program.Dessous"}, - {"name": "LaundryCare.Washer.Program.Monsoon"}, - {"name": "LaundryCare.Washer.Program.Outdoor"}, - {"name": "LaundryCare.Washer.Program.PlushToy"}, - {"name": "LaundryCare.Washer.Program.ShirtsBlouses"}, - {"name": "LaundryCare.Washer.Program.SportFitness"}, - {"name": "LaundryCare.Washer.Program.Towels"}, - {"name": "LaundryCare.Washer.Program.WaterProof"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() @@ -437,23 +361,6 @@ class Washer( class CoffeeMaker(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteStart): """Coffee maker class.""" - PROGRAMS = [ - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto"}, - {"name": "ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado"}, - ] - power_off_state = BSH_POWER_STANDBY def get_entity_info(self): @@ -479,12 +386,6 @@ class Hood( ): """Hood class.""" - PROGRAMS = [ - {"name": "Cooking.Common.Program.Hood.Automatic"}, - {"name": "Cooking.Common.Program.Hood.Venting"}, - {"name": "Cooking.Common.Program.Hood.DelayedShutOff"}, - ] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() @@ -532,8 +433,6 @@ class Freezer(DeviceWithDoor): class Hob(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteControl): """Hob class.""" - PROGRAMS = [{"name": "Cooking.Hob.Program.PowerLevelMode"}] - def get_entity_info(self): """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() From 0666a4750c9b19aafa11da5911088655036ad7a5 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 28 Mar 2023 09:30:42 +0200 Subject: [PATCH 0855/1058] Add re-auth support to philips_js (#88774) * Add re-auth support to philips_js * Adjustments from review * Don't allow duplicate entries for now --- .../components/philips_js/__init__.py | 5 ++- .../components/philips_js/config_flow.py | 42 +++++++++++++++---- .../components/philips_js/strings.json | 3 +- .../components/philips_js/test_config_flow.py | 37 ++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 3145e82a9423..55ac33d198f0 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -19,8 +19,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN @@ -171,4 +172,4 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): except ConnectionFailure: pass except AutenticationFailure as exception: - raise UpdateFailed(str(exception)) from exception + raise ConfigEntryAuthFailed(str(exception)) from exception diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index dab8d4fbe242..9b7e52c2119f 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Philips TV integration.""" from __future__ import annotations +from collections.abc import Mapping import platform from typing import Any @@ -20,6 +21,18 @@ from homeassistant.data_entry_flow import FlowResult from . import LOGGER from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN +USER_SCHEMA = vol.Schema( + { + vol.Required( + CONF_HOST, + ): str, + vol.Required( + CONF_API_VERSION, + default=1, + ): vol.In([1, 5, 6]), + } +) + async def _validate_input( hass: core.HomeAssistant, host: str, api_version: int @@ -47,9 +60,19 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._current: dict[str, Any] = {} self._hub: PhilipsTV | None = None self._pair_state: Any = None + self._entry: config_entries.ConfigEntry | None = None async def _async_create_current(self) -> FlowResult: system = self._current[CONF_SYSTEM] + if self._entry: + self.hass.config_entries.async_update_entry( + self._entry, data=self._entry.data | self._current + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=f"{system['name']} ({system['serialnumber']})", data=self._current, @@ -108,6 +131,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._current[CONF_PASSWORD] = password return await self._async_create_current() + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + self._current[CONF_HOST] = entry_data[CONF_HOST] + self._current[CONF_API_VERSION] = entry_data[CONF_API_VERSION] + return await self.async_step_user() + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -128,7 +158,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: if serialnumber := hub.system.get("serialnumber"): await self.async_set_unique_id(serialnumber) - self._abort_if_unique_id_configured() + if self._entry is None: + self._abort_if_unique_id_configured() self._current[CONF_SYSTEM] = hub.system self._current[CONF_API_VERSION] = hub.api_version @@ -138,14 +169,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_pair() return await self._async_create_current() - schema = vol.Schema( - { - vol.Required(CONF_HOST, default=self._current.get(CONF_HOST)): str, - vol.Required( - CONF_API_VERSION, default=self._current.get(CONF_API_VERSION, 1) - ): vol.In([1, 5, 6]), - } - ) + schema = self.add_suggested_values_to_schema(USER_SCHEMA, self._current) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) @staticmethod diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index dc2583858045..302e1b9accf7 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -22,7 +22,8 @@ "invalid_pin": "Invalid PIN" }, "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%]" } }, "options": { diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 1662a2a3fc26..603e278d5924 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -12,6 +12,7 @@ from . import ( MOCK_CONFIG, MOCK_CONFIG_PAIRED, MOCK_PASSWORD, + MOCK_SYSTEM, MOCK_SYSTEM_UNPAIRED, MOCK_USERINPUT, MOCK_USERNAME, @@ -56,6 +57,42 @@ async def test_form(hass: HomeAssistant, mock_setup_entry) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_reauth( + hass: HomeAssistant, mock_setup_entry, mock_config_entry, mock_tv +) -> None: + """Test we get the form.""" + + mock_tv.system = MOCK_SYSTEM | {"model": "changed"} + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.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["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USERINPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_config_entry.data == MOCK_CONFIG | {"system": mock_tv.system} + assert len(mock_setup_entry.mock_calls) == 2 + + async def test_form_cannot_connect(hass: HomeAssistant, mock_tv) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( From ff135ecdc667d6bd833719368c38637d3fc848e8 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 28 Mar 2023 01:31:36 -0600 Subject: [PATCH 0856/1058] Add a calendar entity to Ridwell (#88108) * Subclass a `DataUpdateCoordinator` for Ridwell * Add a calendar entity to Ridwell * Simpler unique ID * Fix tests * Docstring --- .coveragerc | 1 + homeassistant/components/ridwell/__init__.py | 2 +- homeassistant/components/ridwell/calendar.py | 78 +++++++++++++++++++ .../components/ridwell/coordinator.py | 8 +- .../components/ridwell/diagnostics.py | 6 +- homeassistant/components/ridwell/entity.py | 15 ++-- homeassistant/components/ridwell/sensor.py | 7 +- homeassistant/components/ridwell/switch.py | 16 +++- tests/components/ridwell/conftest.py | 21 +++-- tests/components/ridwell/test_diagnostics.py | 2 +- 10 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/ridwell/calendar.py diff --git a/.coveragerc b/.coveragerc index 520b87b08b90..dfc13304b199 100644 --- a/.coveragerc +++ b/.coveragerc @@ -997,6 +997,7 @@ omit = homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py homeassistant/components/ridwell/__init__.py + homeassistant/components/ridwell/calendar.py homeassistant/components/ridwell/coordinator.py homeassistant/components/ridwell/switch.py homeassistant/components/ring/camera.py diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 116528f4ca85..1b0a83f1c058 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import entity_registry as er from .const import DOMAIN, LOGGER, SENSOR_TYPE_NEXT_PICKUP from .coordinator import RidwellDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py new file mode 100644 index 000000000000..57919ed1feba --- /dev/null +++ b/homeassistant/components/ridwell/calendar.py @@ -0,0 +1,78 @@ +"""Support for Ridwell calendars.""" +from __future__ import annotations + +import datetime + +from aioridwell.model import RidwellAccount, RidwellPickupEvent + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +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 RidwellDataUpdateCoordinator +from .entity import RidwellEntity + + +@callback +def async_get_calendar_event_from_pickup_event( + pickup_event: RidwellPickupEvent, +) -> CalendarEvent: + """Get a HASS CalendarEvent from an aioridwell PickupEvent.""" + pickup_type_string = ", ".join( + [ + f"{pickup.name} (quantity: {pickup.quantity})" + for pickup in pickup_event.pickups + ] + ) + return CalendarEvent( + summary=f"Ridwell Pickup ({pickup_event.state.value})", + description=f"Pickup types: {pickup_type_string}", + start=pickup_event.pickup_date, + end=pickup_event.pickup_date + datetime.timedelta(days=1), + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Ridwell calendars based on a config entry.""" + coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + RidwellCalendar(coordinator, account) + for account in coordinator.accounts.values() + ) + + +class RidwellCalendar(RidwellEntity, CalendarEntity): + """Define a Ridwell calendar.""" + + _attr_icon = "mdi:delete-empty" + + def __init__( + self, coordinator: RidwellDataUpdateCoordinator, account: RidwellAccount + ) -> None: + """Initialize the Ridwell entity.""" + super().__init__(coordinator, account) + + self._attr_unique_id = self._account.account_id + self._event: CalendarEvent | None = None + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return async_get_calendar_event_from_pickup_event(self.next_pickup_event) + + 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.""" + return [ + async_get_calendar_event_from_pickup_event(event) + for event in self.coordinator.data[self._account.account_id] + ] diff --git a/homeassistant/components/ridwell/coordinator.py b/homeassistant/components/ridwell/coordinator.py index a3b83c70aaeb..9561cd26e4b7 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -22,14 +22,14 @@ UPDATE_INTERVAL = timedelta(hours=1) class RidwellDataUpdateCoordinator( - DataUpdateCoordinator[dict[str, RidwellPickupEvent]] + DataUpdateCoordinator[dict[str, list[RidwellPickupEvent]]] ): """Class to manage fetching data from single endpoint.""" config_entry: ConfigEntry def __init__(self, hass: HomeAssistant, *, name: str) -> None: - """Initialize global data updater.""" + """Initialize.""" # These will be filled in by async_initialize; we give them these defaults to # avoid arduous typing checks down the line: self.accounts: dict[str, RidwellAccount] = {} @@ -38,13 +38,13 @@ class RidwellDataUpdateCoordinator( super().__init__(hass, LOGGER, name=name, update_interval=UPDATE_INTERVAL) - async def _async_update_data(self) -> dict[str, RidwellPickupEvent]: + async def _async_update_data(self) -> dict[str, list[RidwellPickupEvent]]: """Fetch the latest data from the source.""" data = {} async def async_get_pickups(account: RidwellAccount) -> None: """Get the latest pickups for an account.""" - data[account.account_id] = await account.async_get_next_pickup_event() + data[account.account_id] = await account.async_get_pickup_events() tasks = [async_get_pickups(account) for account in self.accounts.values()] results = await asyncio.gather(*tasks, return_exceptions=True) diff --git a/homeassistant/components/ridwell/diagnostics.py b/homeassistant/components/ridwell/diagnostics.py index 772efb87ac73..f48861cee197 100644 --- a/homeassistant/components/ridwell/diagnostics.py +++ b/homeassistant/components/ridwell/diagnostics.py @@ -32,7 +32,11 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), - "data": [dataclasses.asdict(event) for event in coordinator.data.values()], + "data": [ + dataclasses.asdict(event) + for events in coordinator.data.values() + for event in events + ], }, TO_REDACT, ) diff --git a/homeassistant/components/ridwell/entity.py b/homeassistant/components/ridwell/entity.py index 29dd68e2a817..9c7ceee7f56b 100644 --- a/homeassistant/components/ridwell/entity.py +++ b/homeassistant/components/ridwell/entity.py @@ -1,8 +1,12 @@ """Define a base Ridwell entity.""" +from __future__ import annotations + +from datetime import date + from aioridwell.model import RidwellAccount, RidwellPickupEvent from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -18,7 +22,6 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]): self, coordinator: RidwellDataUpdateCoordinator, account: RidwellAccount, - description: EntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -31,10 +34,12 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]): manufacturer="Ridwell", name="Ridwell", ) - self._attr_unique_id = f"{account.account_id}_{description.key}" - self.entity_description = description @property def next_pickup_event(self) -> RidwellPickupEvent: """Get the next pickup event.""" - return self.coordinator.data[self._account.account_id] + return next( + event + for event in self.coordinator.data[self._account.account_id] + if event.pickup_date >= date.today() + ) diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 05cee54ba9dc..1eba555e9550 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -27,7 +27,7 @@ ATTR_QUANTITY = "quantity" SENSOR_DESCRIPTION = SensorEntityDescription( key=SENSOR_TYPE_NEXT_PICKUP, - name="Ridwell pickup", + name="Next Ridwell pickup", device_class=SensorDeviceClass.DATE, ) @@ -54,9 +54,10 @@ class RidwellSensor(RidwellEntity, SensorEntity): description: SensorEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator, account, description) + super().__init__(coordinator, account) - self._attr_name = f"{description.name} ({account.address['street1']})" + self._attr_unique_id = f"{account.account_id}_{description.key}" + self.entity_description = description @property def extra_state_attributes(self) -> Mapping[str, Any]: diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index f16bbaebab63..7a948f8b8832 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aioridwell.errors import RidwellError -from aioridwell.model import EventState +from aioridwell.model import EventState, RidwellAccount from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -38,7 +38,19 @@ async def async_setup_entry( class RidwellSwitch(RidwellEntity, SwitchEntity): - """Define a Ridwell button.""" + """Define a Ridwell switch.""" + + def __init__( + self, + coordinator: RidwellDataUpdateCoordinator, + account: RidwellAccount, + description: SwitchEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator, account) + + self._attr_unique_id = f"{account.account_id}_{description.key}" + self.entity_description = description @property def is_on(self) -> bool: diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index e86243da533e..57d485d42813 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -3,6 +3,7 @@ from datetime import date from unittest.mock import AsyncMock, Mock, patch from aioridwell.model import EventState, RidwellPickup, RidwellPickupEvent +from freezegun import freeze_time import pytest from homeassistant.components.ridwell.const import DOMAIN @@ -28,14 +29,16 @@ def account_fixture(): "state": "New York", "postal_code": "10001", }, - async_get_next_pickup_event=AsyncMock( - return_value=RidwellPickupEvent( - None, - "event_123", - date(2022, 1, 24), - [RidwellPickup("Plastic Film", "offer_123", 1, "product_123", 1)], - EventState.INITIALIZED, - ) + async_get_pickup_events=AsyncMock( + return_value=[ + RidwellPickupEvent( + None, + "event_123", + date(2022, 1, 24), + [RidwellPickup("Plastic Film", "offer_123", 1, "product_123", 1)], + EventState.INITIALIZED, + ) + ] ), ) @@ -77,6 +80,8 @@ async def mock_aioridwell_fixture(hass, client, config): ), patch( "homeassistant.components.ridwell.coordinator.async_get_client", return_value=client, + ), freeze_time( + "2022-01-01" ): yield diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index e73b352f3d98..caac4880417e 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -32,7 +32,7 @@ async def test_entry_diagnostics( "_async_request": None, "event_id": "event_123", "pickup_date": { - "__type": "", + "__type": "", "isoformat": "2022-01-24", }, "pickups": [ From 8b7594ae08c2b69d6c2a04644e9e8862e3e5efbe Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Tue, 28 Mar 2023 00:33:32 -0700 Subject: [PATCH 0857/1058] Look up todoist collaborators only when adding new task (#87957) * Look up collaborators only when adding new task. Also fixed a few api call arguments that were incorrect. The `labels` key should have been a list of strings and the `assignee` key should have been `assignee_id`. * Add missing type in test. * Remove print --- homeassistant/components/todoist/calendar.py | 24 +++------- tests/components/todoist/test_calendar.py | 49 +++++++++++++++++--- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 02459b429c4d..645fea865ea6 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -1,9 +1,7 @@ """Support for Todoist task management (https://todoist.com).""" from __future__ import annotations -import asyncio from datetime import date, datetime, timedelta -from itertools import chain import logging from typing import Any import uuid @@ -117,8 +115,6 @@ async def async_setup_platform( # Look up IDs based on (lowercase) names. project_id_lookup = {} - label_id_lookup = {} - collaborator_id_lookup = {} api = TodoistAPIAsync(token) @@ -126,9 +122,6 @@ async def async_setup_platform( # Grab all projects. projects = await api.get_projects() - collaborator_tasks = (api.get_collaborators(project.id) for project in projects) - collaborators = list(chain.from_iterable(await asyncio.gather(*collaborator_tasks))) - # Grab all labels labels = await api.get_labels() @@ -142,13 +135,6 @@ async def async_setup_platform( # Cache the names so we can easily look up name->ID. project_id_lookup[project.name.lower()] = project.id - # Cache all label names - label_id_lookup = {label.name.lower(): label.id for label in labels} - - collaborator_id_lookup = { - collab.name.lower(): collab.id for collab in collaborators - } - # Check config for more projects. extra_projects: list[CustomProject] = config[CONF_EXTRA_PROJECTS] for extra_project in extra_projects: @@ -194,14 +180,16 @@ async def async_setup_platform( data: dict[str, Any] = {"project_id": project_id} if task_labels := call.data.get(LABELS): - data["label_ids"] = [ - label_id_lookup[label.lower()] for label in task_labels - ] + data["labels"] = task_labels if ASSIGNEE in call.data: + collaborators = await api.get_collaborators(project_id) + collaborator_id_lookup = { + collab.name.lower(): collab.id for collab in collaborators + } task_assignee = call.data[ASSIGNEE].lower() if task_assignee in collaborator_id_lookup: - data["assignee"] = collaborator_id_lookup[task_assignee] + data["assignee_id"] = collaborator_id_lookup[task_assignee] else: raise ValueError( f"User is not part of the shared project. user: {task_assignee}" diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index adf0f8a14b05..9c0680d14434 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,14 +1,21 @@ """Unit tests for the Todoist calendar platform.""" -from datetime import datetime, timedelta +from datetime import timedelta from http import HTTPStatus from unittest.mock import AsyncMock, patch import urllib import pytest -from todoist_api_python.models import Due, Label, Project, Task +from todoist_api_python.models import Collaborator, Due, Label, Project, Task from homeassistant import setup -from homeassistant.components.todoist.calendar import DOMAIN +from homeassistant.components.todoist.const import ( + ASSIGNEE, + CONTENT, + DOMAIN, + LABELS, + PROJECT_NAME, + SERVICE_NEW_TASK, +) from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -30,9 +37,7 @@ def mock_task() -> Task: created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=Due( - is_recurring=False, date=datetime.now().strftime("%Y-%m-%d"), string="today" - ), + due=Due(is_recurring=False, date=dt.now().strftime("%Y-%m-%d"), string="today"), id="1", labels=["Label1"], order=1, @@ -68,7 +73,9 @@ def mock_api(task) -> AsyncMock: api.get_labels.return_value = [ Label(id="1", name="Label1", color="1", order=1, is_favorite=False) ] - api.get_collaborators.return_value = [] + api.get_collaborators.return_value = [ + Collaborator(email="user@gmail.com", id="1", name="user") + ] api.get_tasks.return_value = [task] return api @@ -193,3 +200,31 @@ async def test_all_day_event( } ] assert events == expected + + +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_create_task_service_call(todoist_api, hass: HomeAssistant, api) -> None: + """Test api is called correctly after a new task service call.""" + todoist_api.return_value = api + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_NEW_TASK, + {ASSIGNEE: "user", CONTENT: "task", LABELS: ["Label1"], PROJECT_NAME: "Name"}, + ) + await hass.async_block_till_done() + + api.add_task.assert_called_with( + "task", project_id="12345", labels=["Label1"], assignee_id="1" + ) From 706e8d56128787478c0f1a28061005db097a8a5e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 28 Mar 2023 09:35:09 +0200 Subject: [PATCH 0858/1058] Add product calculation to Group sensor (#87373) * Group product * config flow --- homeassistant/components/group/config_flow.py | 1 + homeassistant/components/group/sensor.py | 14 ++++++++++++++ tests/components/group/test_sensor.py | 3 +++ 3 files changed, 18 insertions(+) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 069f74bf7070..53a8fd062641 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -31,6 +31,7 @@ _STATISTIC_MEASURES = [ selector.SelectOptionDict(value="last", label="Most recently updated"), selector.SelectOptionDict(value="range", label="Statistical range"), selector.SelectOptionDict(value="sum", label="Sum"), + selector.SelectOptionDict(value="product", label="Product"), ] diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 265e1640d06d..4c6e8dccc1eb 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -54,6 +54,7 @@ ATTR_LAST = "last" ATTR_LAST_ENTITY_ID = "last_entity_id" ATTR_RANGE = "range" ATTR_SUM = "sum" +ATTR_PRODUCT = "product" SENSOR_TYPES = { ATTR_MIN_VALUE: "min", ATTR_MAX_VALUE: "max", @@ -62,6 +63,7 @@ SENSOR_TYPES = { ATTR_LAST: "last", ATTR_RANGE: "range", ATTR_SUM: "sum", + ATTR_PRODUCT: "product", } SENSOR_TYPE_TO_ATTR = {v: k for k, v in SENSOR_TYPES.items()} @@ -226,6 +228,17 @@ def calc_sum( return {}, result +def calc_product( + sensor_values: list[tuple[str, float, State]] +) -> tuple[dict[str, str | None], float]: + """Calculate a product of values.""" + result = 1.0 + for _, sensor_value, _ in sensor_values: + result *= sensor_value + + return {}, result + + CALC_TYPES: dict[ str, Callable[ @@ -239,6 +252,7 @@ CALC_TYPES: dict[ "last": calc_last, "range": calc_range, "sum": calc_sum, + "product": calc_product, } diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index 5f85aa648542..39c9b788d566 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the Group Sensor platform.""" from __future__ import annotations +from math import prod import statistics from typing import Any from unittest.mock import patch @@ -45,6 +46,7 @@ MEAN = statistics.mean(VALUES) MEDIAN = statistics.median(VALUES) RANGE = max(VALUES) - min(VALUES) SUM_VALUE = sum(VALUES) +PRODUCT_VALUE = prod(VALUES) @pytest.mark.parametrize( @@ -57,6 +59,7 @@ SUM_VALUE = sum(VALUES) ("last", VALUES[2], {ATTR_LAST_ENTITY_ID: "sensor.test_3"}), ("range", RANGE, {}), ("sum", SUM_VALUE, {}), + ("product", PRODUCT_VALUE, {}), ], ) async def test_sensors( From 5e03272821d6923353b3554ec51d8f70aa9e24c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 09:36:34 +0200 Subject: [PATCH 0859/1058] Bump pychromecast to 13.0.6 (#90390) --- 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 cc4a130a251a..be80ca340ffd 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.4"], + "requirements": ["pychromecast==13.0.6"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e0df5121a017..c3dfa944f11b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1540,7 +1540,7 @@ pycfdns==2.0.1 pychannels==1.2.3 # homeassistant.components.cast -pychromecast==13.0.4 +pychromecast==13.0.6 # homeassistant.components.pocketcasts pycketcasts==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ceafe5d1738c..6edc309c811e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1128,7 +1128,7 @@ pybravia==0.3.2 pycfdns==2.0.1 # homeassistant.components.cast -pychromecast==13.0.4 +pychromecast==13.0.6 # homeassistant.components.comfoconnect pycomfoconnect==0.5.1 From 14ffda975893ebe897ef01e10458f941cec878ac Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Mar 2023 09:37:07 +0200 Subject: [PATCH 0860/1058] Remove dependency on async_setup from mqtt integration (#87987) * Remove async_setup from mqtt integration * Final update common tests * Related tests init * Related tests diagnostics * Related tests config_flow * Cleanup and correct test * Keep websockets_api commands in async_setup --- homeassistant/components/mqtt/__init__.py | 143 +++---------- homeassistant/components/mqtt/config_flow.py | 2 +- homeassistant/components/mqtt/mixins.py | 2 +- tests/components/mqtt/test_common.py | 69 +++---- tests/components/mqtt/test_config_flow.py | 206 +++++++------------ tests/components/mqtt/test_diagnostics.py | 2 - tests/components/mqtt/test_init.py | 157 ++------------ 7 files changed, 153 insertions(+), 428 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 5a9eb7c3fcb2..24dc4b67cd9b 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -10,7 +10,7 @@ from typing import Any, cast import jinja2 import voluptuous as vol -from homeassistant import config as conf_util, config_entries +from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -25,16 +25,10 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import TemplateError, Unauthorized -from homeassistant.helpers import ( - config_validation as cv, - discovery_flow, - event, - template, -) +from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.reload import ( async_integration_yaml_config, async_reload_integration_platforms, @@ -52,11 +46,9 @@ from .client import ( # noqa: F401 subscribe, ) from .config_integration import ( - CONFIG_SCHEMA_BASE, CONFIG_SCHEMA_ENTRY, DEFAULT_VALUES, - DEPRECATED_CERTIFICATE_CONFIG_KEYS, - DEPRECATED_CONFIG_KEYS, + PLATFORM_CONFIG_SCHEMA_BASE, ) from .const import ( # noqa: F401 ATTR_PAYLOAD, @@ -99,7 +91,6 @@ from .models import ( # noqa: F401 from .util import ( async_create_certificate_temp_files, get_mqtt_data, - migrate_certificate_file_to_content, mqtt_config_entry_enabled, valid_publish_topic, valid_qos_schema, @@ -146,22 +137,22 @@ CONFIG_ENTRY_CONFIG_KEYS = [ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( - cv.deprecated(CONF_BIRTH_MESSAGE), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_BROKER), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_CERTIFICATE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_ID), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_CERT), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_CLIENT_KEY), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_DISCOVERY), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_DISCOVERY_PREFIX), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_KEEPALIVE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_PASSWORD), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_PORT), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_PROTOCOL), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_TLS_INSECURE), # Deprecated in HA Core 2022.11 - cv.deprecated(CONF_USERNAME), # Deprecated in HA Core 2022.3 - cv.deprecated(CONF_WILL_MESSAGE), # Deprecated in HA Core 2022.3 - CONFIG_SCHEMA_BASE, + cv.removed(CONF_BIRTH_MESSAGE), # Removed in HA Core 2023.4 + cv.removed(CONF_BROKER), # Removed in HA Core 2023.4 + cv.removed(CONF_CERTIFICATE), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_ID), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_CERT), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_KEY), # Removed in HA Core 2023.4 + cv.removed(CONF_DISCOVERY), # Removed in HA Core 2022.3 + cv.removed(CONF_DISCOVERY_PREFIX), # Removed in HA Core 2023.4 + cv.removed(CONF_KEEPALIVE), # Removed in HA Core 2023.4 + cv.removed(CONF_PASSWORD), # Removed in HA Core 2023.4 + cv.removed(CONF_PORT), # Removed in HA Core 2023.4 + cv.removed(CONF_PROTOCOL), # Removed in HA Core 2023.4 + cv.removed(CONF_TLS_INSECURE), # Removed in HA Core 2023.4 + cv.removed(CONF_USERNAME), # Removed in HA Core 2023.4 + cv.removed(CONF_WILL_MESSAGE), # Removed in HA Core 2023.4 + PLATFORM_CONFIG_SCHEMA_BASE, ) }, extra=vol.ALLOW_EXTRA, @@ -197,34 +188,8 @@ async def _async_setup_discovery( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT protocol service.""" - mqtt_data = get_mqtt_data(hass, True) - - conf: ConfigType | None = config.get(DOMAIN) - websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_mqtt_info) - - if conf: - conf = dict(conf) - mqtt_data.config = conf - - if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is None: - # Create an import flow if the user has yaml configured entities etc. - # but no broker configuration. Note: The intention is not for this to - # import broker configuration from YAML because that has been deprecated. - discovery_flow.async_create_flow( - hass, - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={}, - ) - mqtt_data.reload_needed = True - elif mqtt_entry_status is False: - _LOGGER.info( - "MQTT will be not available until the config entry is enabled", - ) - mqtt_data.reload_needed = True - return True @@ -247,30 +212,15 @@ def _filter_entry_config(hass: HomeAssistant, entry: ConfigEntry) -> None: hass.config_entries.async_update_entry(entry, data=filtered_data) -async def _async_merge_basic_config( +async def _async_auto_mend_config( hass: HomeAssistant, entry: ConfigEntry, yaml_config: dict[str, Any] ) -> None: - """Merge basic options in configuration.yaml config with config entry. + """Mends config fetched from config entry and adds missing values. This mends incomplete migration from old version of HA Core. """ entry_updated = False entry_config = {**entry.data} - for key in DEPRECATED_CERTIFICATE_CONFIG_KEYS: - if key in yaml_config and key not in entry_config: - if ( - content := await hass.async_add_executor_job( - migrate_certificate_file_to_content, yaml_config[key] - ) - ) is not None: - entry_config[key] = content - entry_updated = True - - for key in DEPRECATED_CONFIG_KEYS: - if key in yaml_config and key not in entry_config: - entry_config[key] = yaml_config[key] - entry_updated = True - for key in MANDATORY_DEFAULT_VALUES: if key not in entry_config: entry_config[key] = DEFAULT_VALUES[key] @@ -298,17 +248,16 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - async def async_fetch_config( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any] | None: - """Fetch fresh MQTT yaml config from the hass config when (re)loading the entry.""" + """Fetch fresh MQTT yaml config from the hass config.""" mqtt_data = get_mqtt_data(hass) - if mqtt_data.reload_entry: - hass_config = await conf_util.async_hass_config_yaml(hass) - mqtt_data.config = CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) + hass_config = await conf_util.async_hass_config_yaml(hass) + mqtt_data.config = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) # Remove unknown keys from config entry data _filter_entry_config(hass, entry) - # Merge basic configuration, and add missing defaults for basic options - await _async_merge_basic_config(hass, entry, mqtt_data.config or {}) + # Add missing defaults to migrate older config entries + await _async_auto_mend_config(hass, entry, mqtt_data.config or {}) # Bail out if broker setting is missing if CONF_BROKER not in entry.data: _LOGGER.error("MQTT broker is not configured, please configure it") @@ -319,37 +268,6 @@ async def async_fetch_config( if (conf := mqtt_data.config) is None: conf = CONFIG_SCHEMA_ENTRY(dict(entry.data)) - # User has configuration.yaml config, warn about config entry overrides - elif any(key in conf for key in entry.data): - shared_keys = conf.keys() & entry.data.keys() - override = {k: entry.data[k] for k in shared_keys if conf[k] != entry.data[k]} - if CONF_PASSWORD in override: - override[CONF_PASSWORD] = "********" - if CONF_CLIENT_KEY in override: - override[CONF_CLIENT_KEY] = "-----PRIVATE KEY-----" - if override: - _LOGGER.warning( - ( - "Deprecated configuration settings found in configuration.yaml. " - "These settings from your configuration entry will override: %s" - ), - override, - ) - # Register a repair issue - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml_broker_settings", - breaks_in_ha_version="2023.4.0", # Warning first added in 2022.11.0 - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml_broker_settings", - translation_placeholders={ - "more_info_url": "https://www.home-assistant.io/integrations/mqtt/", - "deprecated_settings": str(shared_keys)[1:-1], - }, - ) - # Merge advanced configuration values from configuration.yaml conf = _merge_extended_config(entry, conf) return conf @@ -359,10 +277,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" mqtt_data = get_mqtt_data(hass, True) - # Merge basic configuration, and add missing defaults for basic options + # Fetch configuration and add missing defaults for basic options if (conf := await async_fetch_config(hass, entry)) is None: # Bail out return False + await async_create_certificate_temp_files(hass, dict(entry.data)) mqtt_data.client = MQTT(hass, entry, conf) # Restore saved subscriptions @@ -480,6 +399,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" + # Fetch updated manual configured items and validate + config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + mqtt_data.updated_config = config_yaml.get(DOMAIN, {}) + # Reload the modern yaml platforms mqtt_platforms = async_get_platforms(hass, DOMAIN) tasks = [ @@ -493,8 +416,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] await asyncio.gather(*tasks) - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} - mqtt_data.updated_config = config_yaml.get(DOMAIN, {}) await asyncio.gather( *( [ diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 66424f2c3dce..77c3856aac10 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -588,7 +588,7 @@ async def async_get_broker_settings( current_user = user_input_basic.get(CONF_USERNAME) current_pass = user_input_basic.get(CONF_PASSWORD) else: - # Get default settings from entry or yaml (if any) + # Get default settings from entry (if any) current_broker = current_config.get(CONF_BROKER) current_port = current_config.get(CONF_PORT, DEFAULT_PORT) current_user = current_config.get(CONF_USERNAME) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index b52c57ce24ff..cecb4b88bcdd 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -247,7 +247,7 @@ def warn_for_legacy_schema(domain: str) -> Callable[[ConfigType], ConfigType]: ( "Manually configured MQTT %s(s) found under platform key '%s', " "please move to the mqtt integration key, see " - "https://www.home-assistant.io/integrations/%s.mqtt/#new_format" + "https://www.home-assistant.io/integrations/%s.mqtt/" ), domain, domain, diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 6d238a63f433..154f91974a11 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -36,7 +36,6 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient @@ -109,14 +108,15 @@ async def help_setup_component( item += 1 topic = f"homeassistant/{domain}/item_{item}/config" async_fire_mqtt_message(hass, topic, json.dumps(comp)) + await hass.async_block_till_done() else: - await async_setup_component( - hass, - mqtt.DOMAIN, - config, + entry = MockConfigEntry( + domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"} ) + entry.add_to_hass(hass) + with patch("homeassistant.config.load_yaml_config_file", return_value=config): + await entry.async_setup(hass) mqtt_mock = None - await hass.async_block_till_done() return mqtt_mock @@ -226,7 +226,7 @@ async def help_test_default_availability_payload( async def help_test_default_availability_list_payload( hass: HomeAssistant, - mqtt_mock_entry_with_no_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, domain: str, config: ConfigType, no_assumed_state: bool = False, @@ -243,7 +243,7 @@ async def help_test_default_availability_list_payload( {"topic": "availability-topic1"}, {"topic": "availability-topic2"}, ] - await help_setup_component(hass, mqtt_mock_entry_with_no_config, domain, config) + await help_setup_component(hass, mqtt_mock_entry_no_yaml_config, domain, config) state = hass.states.get(f"{domain}.test") assert state and state.state == STATE_UNAVAILABLE @@ -1169,7 +1169,7 @@ async def help_test_entity_id_update_subscriptions( entity_registry = er.async_get(hass) mqtt_mock = await help_setup_component( - hass, mqtt_mock_entry_no_yaml_config, domain, config, True + hass, mqtt_mock_entry_no_yaml_config, domain, config, use_discovery=True ) assert mqtt_mock is not None @@ -1796,27 +1796,6 @@ async def help_test_reload_with_config( await hass.async_block_till_done() -async def help_test_entry_reload_with_new_config( - hass: HomeAssistant, tmp_path: Path, new_config: ConfigType -) -> None: - """Test reloading with supplied config.""" - mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - assert mqtt_config_entry.state is ConfigEntryState.LOADED - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump(new_config) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - - with patch.object( - module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file - ), patch("paho.mqtt.client.Client") as mock_client: - mock_client().connect = lambda *args: 0 - # reload the config entry - assert await hass.config_entries.async_reload(mqtt_config_entry.entry_id) - assert mqtt_config_entry.state is ConfigEntryState.LOADED - await hass.async_block_till_done() - - async def help_test_reloadable( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1839,10 +1818,8 @@ async def help_test_reloadable( entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 - # We should call await mqtt.async_setup_entry(hass, entry) when async_setup - # is removed (this is planned with #87987). Until then we set up the mqtt component - # to test reload after the async_setup setup has set the initial config - await help_setup_component(hass, None, domain, old_config, use_discovery=False) + with patch("homeassistant.config.load_yaml_config_file", return_value=old_config): + await entry.async_setup(hass) assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") @@ -1860,15 +1837,15 @@ async def help_test_reloadable( new_config = { mqtt.DOMAIN: {domain: [new_config_1, new_config_2, new_config_extra]}, } - module_hass_config.load_yaml_config_file.return_value = new_config - # Reload the mqtt entry with the new config - await hass.services.async_call( - "mqtt", - SERVICE_RELOAD, - {}, - blocking=True, - ) - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value=new_config): + # Reload the mqtt entry with the new config + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() assert len(hass.states.async_all(domain)) == 3 @@ -1900,9 +1877,9 @@ async def help_test_unload_config_entry_with_platform( config_setup: dict[str, dict[str, Any]] = copy.deepcopy(config) config_setup[mqtt.DOMAIN][domain]["name"] = "config_setup" config_name = config_setup - await help_setup_component( - hass, mqtt_mock_entry_no_yaml_config, domain, config_setup - ) + + with patch("homeassistant.config.load_yaml_config_file", return_value=config_name): + await mqtt_mock_entry_no_yaml_config() # prepare setup through discovery discovery_setup = copy.deepcopy(config[mqtt.DOMAIN][domain]) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 99a90bac83a2..ae7c4089e54b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -9,13 +9,11 @@ from uuid import uuid4 import pytest import voluptuous as vol -import yaml -from homeassistant import config as hass_config, config_entries, data_entry_flow +from homeassistant import config_entries, data_entry_flow from homeassistant.components import mqtt from homeassistant.components.hassio import HassioServiceInfo from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -267,39 +265,13 @@ async def test_user_connection_fails( assert len(mock_finish_setup.mock_calls) == 0 -async def test_manual_config_starts_discovery_flow( - hass: HomeAssistant, - mock_try_connection: MqttMockPahoClient, - mock_finish_setup: MagicMock, -) -> None: - """Test manual config initiates a discovery flow.""" - # No flows in progress - assert hass.config_entries.flow.async_progress() == [] - - # MQTT config present in yaml config - assert await async_setup_component(hass, "mqtt", {"mqtt": {}}) - await hass.async_block_till_done() - assert len(mock_finish_setup.mock_calls) == 0 - - # There should now be a discovery flow - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["context"]["source"] == "integration_discovery" - assert flows[0]["handler"] == "mqtt" - assert flows[0]["step_id"] == "broker" - - +@pytest.mark.parametrize("hass_config", [{"mqtt": {"sensor": {"state_topic": "test"}}}]) async def test_manual_config_set( hass: HomeAssistant, mock_try_connection: MqttMockPahoClient, mock_finish_setup: MagicMock, ) -> None: """Test manual config does not create an entry, and entry can be setup late.""" - # MQTT config present in yaml config - assert await async_setup_component(hass, "mqtt", {"mqtt": {"broker": "bla"}}) - await hass.async_block_till_done() - # do not try to reload - hass.data["mqtt"].reload_needed = False assert len(mock_finish_setup.mock_calls) == 0 mock_try_connection.return_value = True @@ -1162,6 +1134,9 @@ async def test_options_bad_will_message_fails( } +@pytest.mark.parametrize( + "hass_config", [{"mqtt": {"sensor": [{"state_topic": "some-topic"}]}}] +) async def test_try_connection_with_advanced_parameters( hass: HomeAssistant, mock_try_connection_success: MqttMockPahoClient, @@ -1170,23 +1145,6 @@ async def test_try_connection_with_advanced_parameters( mock_process_uploaded_file: MagicMock, ) -> None: """Test config flow with advanced parameters from config.""" - - with open(tmp_path / "client.crt", "wb") as certfile: - certfile.write(MOCK_CLIENT_CERT) - with open(tmp_path / "client.key", "wb") as keyfile: - keyfile.write(MOCK_CLIENT_KEY) - - config = { - "certificate": "auto", - "tls_insecure": True, - "client_cert": str(tmp_path / "client.crt"), - "client_key": str(tmp_path / "client.key"), - } - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({mqtt.DOMAIN: config}) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - config_entry = MockConfigEntry(domain=mqtt.DOMAIN) config_entry.add_to_hass(hass) config_entry.data = { @@ -1195,6 +1153,10 @@ async def test_try_connection_with_advanced_parameters( mqtt.CONF_USERNAME: "user", mqtt.CONF_PASSWORD: "pass", mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_CERTIFICATE: "auto", + mqtt.CONF_TLS_INSECURE: True, + mqtt.CONF_CLIENT_CERT: MOCK_CLIENT_CERT.decode(encoding="utf-8)"), + mqtt.CONF_CLIENT_KEY: MOCK_CLIENT_KEY.decode(encoding="utf-8"), mqtt.CONF_WS_PATH: "/path/", mqtt.CONF_WS_HEADERS: {"h1": "v1", "h2": "v2"}, mqtt.CONF_KEEPALIVE: 30, @@ -1212,95 +1174,81 @@ async def test_try_connection_with_advanced_parameters( mqtt.ATTR_RETAIN: False, }, } + # Test default/suggested values from config + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "broker" + defaults = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + "set_client_cert": True, + "set_ca_cert": "auto", + } + suggested = { + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "pass", + mqtt.CONF_TLS_INSECURE: True, + mqtt.CONF_PROTOCOL: "3.1.1", + mqtt.CONF_TRANSPORT: "websockets", + mqtt.CONF_WS_PATH: "/path/", + mqtt.CONF_WS_HEADERS: '{"h1":"v1","h2":"v2"}', + } + for k, v in defaults.items(): + assert get_default(result["data_schema"].schema, k) == v + for k, v in suggested.items(): + assert get_suggested(result["data_schema"].schema, k) == v - with patch.object(hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await hass.async_block_till_done() - # Test default/suggested values from config - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "broker" - defaults = { - mqtt.CONF_BROKER: "test-broker", - mqtt.CONF_PORT: 1234, - "set_client_cert": True, + # test we can change username and password + # as it was configured as auto in configuration.yaml is is migrated now + mock_try_connection_success.reset_mock() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "us3r", + mqtt.CONF_PASSWORD: "p4ss", "set_ca_cert": "auto", - } - suggested = { - mqtt.CONF_USERNAME: "user", - mqtt.CONF_PASSWORD: "pass", + "set_client_cert": True, mqtt.CONF_TLS_INSECURE: True, - mqtt.CONF_PROTOCOL: "3.1.1", mqtt.CONF_TRANSPORT: "websockets", - mqtt.CONF_WS_PATH: "/path/", - mqtt.CONF_WS_HEADERS: '{"h1":"v1","h2":"v2"}', - } - for k, v in defaults.items(): - assert get_default(result["data_schema"].schema, k) == v - for k, v in suggested.items(): - assert get_suggested(result["data_schema"].schema, k) == v + mqtt.CONF_WS_PATH: "/new/path", + mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "options" + await hass.async_block_till_done() - # test the client cert and key were migrated to the entry - assert config_entry.data[mqtt.CONF_CLIENT_CERT] == MOCK_CLIENT_CERT.decode( - "utf-8" - ) - assert config_entry.data[mqtt.CONF_CLIENT_KEY] == MOCK_CLIENT_KEY.decode( - "utf-8" - ) - assert config_entry.data[mqtt.CONF_CERTIFICATE] == "auto" + # check if the username and password was set from config flow and not from configuration.yaml + assert mock_try_connection_success.username_pw_set.mock_calls[0][1] == ( + "us3r", + "p4ss", + ) + # check if tls_insecure_set is called + assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - # test we can change username and password - # as it was configured as auto in configuration.yaml is is migrated now - mock_try_connection_success.reset_mock() - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - mqtt.CONF_BROKER: "another-broker", - mqtt.CONF_PORT: 2345, - mqtt.CONF_USERNAME: "us3r", - mqtt.CONF_PASSWORD: "p4ss", - "set_ca_cert": "auto", - "set_client_cert": True, - mqtt.CONF_TLS_INSECURE: True, - mqtt.CONF_TRANSPORT: "websockets", - mqtt.CONF_WS_PATH: "/new/path", - mqtt.CONF_WS_HEADERS: '{"h3": "v3"}', - }, - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {} - assert result["step_id"] == "options" - await hass.async_block_till_done() + # check if the ca certificate settings were not set during connection test + assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ + "certfile" + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) + assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ + "keyfile" + ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) - # check if the username and password was set from config flow and not from configuration.yaml - assert mock_try_connection_success.username_pw_set.mock_calls[0][1] == ( - "us3r", - "p4ss", - ) - # check if tls_insecure_set is called - assert mock_try_connection_success.tls_insecure_set.mock_calls[0][1] == (True,) - - # check if the ca certificate settings were not set during connection test - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "certfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_CERT) - assert mock_try_connection_success.tls_set.mock_calls[0].kwargs[ - "keyfile" - ] == mqtt.util.get_file_path(mqtt.CONF_CLIENT_KEY) - - # check if websockets options are set - assert mock_try_connection_success.ws_set_options.mock_calls[0][1] == ( - "/new/path", - {"h3": "v3"}, - ) - - # Accept default option - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - await hass.async_block_till_done() + # check if websockets options are set + assert mock_try_connection_success.ws_set_options.mock_calls[0][1] == ( + "/new/path", + {"h3": "v3"}, + ) + # Accept default option + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() async def test_setup_with_advanced_settings( diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index 780be7292592..cfc6f069b021 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -25,8 +25,6 @@ default_config = { "port": 1883, "protocol": "3.1.1", "transport": "tcp", - "ws_headers": {}, - "ws_path": "/", "will_message": { "payload": "offline", "qos": 0, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 9fbca57e3a91..cdc31429e2fc 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -5,18 +5,15 @@ import copy from datetime import datetime, timedelta from functools import partial import json -from pathlib import Path import ssl from typing import Any, TypedDict from unittest.mock import ANY, MagicMock, call, mock_open, patch import pytest import voluptuous as vol -import yaml -from homeassistant import config as module_hass_config from homeassistant.components import mqtt -from homeassistant.components.mqtt import CONFIG_SCHEMA, debug_info +from homeassistant.components.mqtt import debug_info from homeassistant.components.mqtt.client import EnsureJobAfterCooldown from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.components.mqtt.models import MessageCallbackType, ReceiveMessage @@ -40,10 +37,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .test_common import ( - help_test_entry_reload_with_new_config, - help_test_validate_platform_config, -) +from .test_common import help_test_validate_platform_config from tests.common import ( MockConfigEntry, @@ -1529,18 +1523,17 @@ async def test_subscribed_at_highest_qos( async def test_reload_entry_with_restored_subscriptions( hass: HomeAssistant, - tmp_path: Path, mqtt_client_mock: MqttMockPahoClient, record_calls: MessageCallbackType, calls: list[ReceiveMessage], ) -> None: """Test reloading the config entry with with subscriptions restored.""" - + # Setup the MQTT entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) mqtt_client_mock.connect.return_value = 0 - assert await mqtt.async_setup_entry(hass, entry) - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + await entry.async_setup(hass) await mqtt.async_subscribe(hass, "test-topic", record_calls) await mqtt.async_subscribe(hass, "wild/+/card", record_calls) @@ -1557,10 +1550,10 @@ async def test_reload_entry_with_restored_subscriptions( calls.clear() # Reload the entry - config_yaml_new = {} - await help_test_entry_reload_with_new_config(hass, tmp_path, config_yaml_new) - - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + assert await hass.config_entries.async_reload(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + await hass.async_block_till_done() async_fire_mqtt_message(hass, "test-topic", "test-payload2") async_fire_mqtt_message(hass, "wild/any/card", "wild-card-payload2") @@ -1574,10 +1567,10 @@ async def test_reload_entry_with_restored_subscriptions( calls.clear() # Reload the entry again - config_yaml_new = {} - await help_test_entry_reload_with_new_config(hass, tmp_path, config_yaml_new) - - await hass.async_block_till_done() + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + assert await hass.config_entries.async_reload(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + await hass.async_block_till_done() async_fire_mqtt_message(hass, "test-topic", "test-payload3") async_fire_mqtt_message(hass, "wild/any/card", "wild-card-payload3") @@ -1804,55 +1797,6 @@ async def test_handle_message_callback( assert callbacks[0].payload == "test-payload" -async def test_setup_override_configuration( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path -) -> None: - """Test override setup from configuration entry.""" - calls_username_password_set = [] - - def mock_usename_password_set(username: str, password: str) -> None: - calls_username_password_set.append((username, password)) - - # Mock password setup from config - config = { - "username": "someuser", - "password": "someyamlconfiguredpassword", - "protocol": "3.1", - } - new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({mqtt.DOMAIN: config}) - new_yaml_config_file.write_text(new_yaml_config) - assert new_yaml_config_file.read_text() == new_yaml_config - - with patch.object(module_hass_config, "YAML_CONFIG_FILE", new_yaml_config_file): - # Mock config entry - entry = MockConfigEntry( - domain=mqtt.DOMAIN, - data={mqtt.CONF_BROKER: "test-broker", "password": "somepassword"}, - ) - entry.add_to_hass(hass) - - with patch("paho.mqtt.client.Client") as mock_client: - mock_client().username_pw_set = mock_usename_password_set - mock_client.on_connect(return_value=0) - await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) - await entry.async_setup(hass) - await hass.async_block_till_done() - - assert ( - "Deprecated configuration settings found in configuration.yaml. " - "These settings from your configuration entry will override:" - in caplog.text - ) - - # Check if the protocol was set to 3.1 from configuration.yaml - assert mock_client.call_args[1]["protocol"] == 3 - - # Check if the password override worked - assert calls_username_password_set[0][0] == "someuser" - assert calls_username_password_set[0][1] == "somepassword" - - @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_manual_mqtt_with_platform_key( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -2312,39 +2256,10 @@ async def test_mqtt_subscribes_topics_on_connect( mqtt_client_mock.subscribe.assert_any_call("still/pending", 1) -async def test_setup_entry_with_config_override( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, -) -> None: - """Test if the MQTT component loads with no config and config entry can be setup.""" - data = ( - '{ "device":{"identifiers":["0AFFD2"]},' - ' "state_topic": "foobar/sensor",' - ' "unique_id": "unique" }' - ) - - # mqtt present in yaml config - assert await async_setup_component(hass, mqtt.DOMAIN, {}) - await hass.async_block_till_done() - - # User sets up a config entry - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) - entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - # Discover a device to verify the entry was setup correctly - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) - await hass.async_block_till_done() - - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) - assert device_entry is not None - - async def test_update_incomplete_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, caplog: pytest.LogCaptureFixture, ) -> None: @@ -2356,24 +2271,17 @@ async def test_update_incomplete_entry( ) # Config entry data is incomplete - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={"port": 1234}) - entry.add_to_hass(hass) - # Mqtt present in yaml config - config = {"broker": "yaml_broker"} - await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + entry.data = {"broker": "test-broker", "port": 1234} + await mqtt_mock_entry_no_yaml_config() await hass.async_block_till_done() # Config entry data should now be updated assert dict(entry.data) == { + "broker": "test-broker", "port": 1234, "discovery_prefix": "homeassistant", - "broker": "yaml_broker", } - # Warnings about broker deprecated, but not about other keys with default values - assert ( - "The 'broker' option is deprecated, please remove it from your configuration" - in caplog.text - ) # Discover a device to verify the entry was setup correctly async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) @@ -3219,7 +3127,7 @@ async def test_subscribe_connection_status( # This warning and test is to be removed from HA core 2023.6 async def test_one_deprecation_warning_per_platform( hass: HomeAssistant, - mqtt_mock_entry_with_yaml_config: MqttMockHAClientGenerator, + mqtt_mock_entry_no_yaml_config: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test a deprecation warning is is logged once per platform.""" @@ -3230,8 +3138,6 @@ async def test_one_deprecation_warning_per_platform( config2 = copy.deepcopy(config) config2["name"] = "test2" await async_setup_component(hass, platform, {platform: [config1, config2]}) - await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() count = 0 for record in caplog.records: if record.levelname == "ERROR" and ( @@ -3242,13 +3148,6 @@ async def test_one_deprecation_warning_per_platform( assert count == 1 -async def test_config_schema_validation(hass: HomeAssistant) -> None: - """Test invalid platform options in the config schema do not pass the config validation.""" - config = {"mqtt": {"sensor": [{"some_illegal_topic": "mystate/topic/path"}]}} - with pytest.raises(vol.MultipleInvalid): - CONFIG_SCHEMA(config) - - @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_unload_config_entry( hass: HomeAssistant, @@ -3277,24 +3176,6 @@ async def test_unload_config_entry( assert "No ACK from MQTT server" not in caplog.text -@patch("homeassistant.components.mqtt.PLATFORMS", []) -async def test_setup_with_disabled_entry( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setting up the platform with a disabled config entry.""" - # Try to setup the platform with a disabled config entry - config_entry = MockConfigEntry( - domain=mqtt.DOMAIN, data={}, disabled_by=ConfigEntryDisabler.USER - ) - config_entry.add_to_hass(hass) - - config: ConfigType = {mqtt.DOMAIN: {}} - await async_setup_component(hass, mqtt.DOMAIN, config) - await hass.async_block_till_done() - - assert "MQTT will be not available until the config entry is enabled" in caplog.text - - @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_publish_or_subscribe_without_valid_config_entry( hass: HomeAssistant, record_calls: MessageCallbackType From c51ed4b3281368b6724610e6c7c4374f90367f52 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 28 Mar 2023 03:59:01 -0400 Subject: [PATCH 0861/1058] Redact secret zwave values in diagnostics (#90389) * redact secret zwave values from diagnostics * shhrink * rename --- .../components/zwave_js/diagnostics.py | 20 ++++++--- tests/components/zwave_js/test_diagnostics.py | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index acb87a239ae3..4f52c41a0854 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -34,16 +34,23 @@ VALUES_TO_REDACT = ( ) -def redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: - """Redact value of a Z-Wave value.""" +def _redacted_value(zwave_value: ValueDataType) -> ValueDataType: + """Return redacted value of a Z-Wave value.""" + redacted_value: ValueDataType = deepcopy(zwave_value) + redacted_value["value"] = REDACTED + return redacted_value + + +def optionally_redact_value_of_zwave_value(zwave_value: ValueDataType) -> ValueDataType: + """Redact value of a Z-Wave value if it matches criteria to redact.""" # If the value has no value, there is nothing to redact if zwave_value.get("value") in (None, ""): return zwave_value + if zwave_value.get("metadata", {}).get("secret"): + return _redacted_value(zwave_value) for value_to_redact in VALUES_TO_REDACT: if value_matches_matcher(value_to_redact, zwave_value): - redacted_value: ValueDataType = deepcopy(zwave_value) - redacted_value["value"] = REDACTED - return redacted_value + return _redacted_value(zwave_value) return zwave_value @@ -51,7 +58,8 @@ def redact_node_state(node_state: NodeDataType) -> NodeDataType: """Redact node state.""" redacted_state: NodeDataType = deepcopy(node_state) redacted_state["values"] = [ - redact_value_of_zwave_value(zwave_value) for zwave_value in node_state["values"] + optionally_redact_value_of_zwave_value(zwave_value) + for zwave_value in node_state["values"] ] return redacted_state diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index 773b799cd6f2..c7a711d10671 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -1,10 +1,14 @@ """Test the Z-Wave JS diagnostics.""" +import copy from unittest.mock import patch import pytest +from zwave_js_server.const import CommandClass from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.zwave_js.diagnostics import ( + REDACTED, ZwaveValueMatcher, async_get_device_diagnostics, ) @@ -179,3 +183,41 @@ async def test_device_diagnostics_missing_primary_value( assert air_entity["value_id"] == value.value_id assert air_entity["primary_value"] is None + + +async def test_device_diagnostics_secret_value( + hass: HomeAssistant, + client, + multisensor_6_state, + integration, + hass_client: ClientSessionGenerator, + version_state, +) -> None: + """Test that secret value in device level diagnostics gets redacted.""" + + def _find_ultraviolet_val(data: dict) -> dict: + """Find ultraviolet property value in data.""" + return next( + val + for val in data["values"] + if val["commandClass"] == CommandClass.SENSOR_MULTILEVEL + and val["property"] == PROPERTY_ULTRAVIOLET + ) + + node_state = copy.deepcopy(multisensor_6_state) + # Force a value to be secret so we can check if it gets redacted + secret_value = _find_ultraviolet_val(node_state) + secret_value["metadata"]["secret"] = True + node = Node(client, node_state) + client.driver.controller.nodes[node.node_id] = node + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device({get_device_id(client.driver, node)}) + assert device + + diagnostics_data = await get_diagnostics_for_device( + hass, hass_client, integration, device + ) + test_value = _find_ultraviolet_val(diagnostics_data["state"]) + assert test_value["value"] == REDACTED From 190393c6bbb741041a26c342a29bb60b51079396 Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Tue, 28 Mar 2023 05:17:33 -0300 Subject: [PATCH 0862/1058] Improve Proxmox VE type hints (#90359) * Improves some type hints in Proxmox VE * update * update] * fix isort * Fix vm_id type * Fix vm_id type * Update homeassistant/components/proxmoxve/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/proxmoxve/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Change initialization of _proxmox * Move definition of _proxmox to class level --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/proxmoxve/__init__.py | 54 ++++++++++++------- .../components/proxmoxve/binary_sensor.py | 22 +++++--- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 7ea4cac58dd0..2764f22b0806 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Any from proxmoxer import AuthenticationError, ProxmoxAPI from proxmoxer.core import ResourceException @@ -185,14 +186,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def create_coordinator_container_vm( - hass, proxmox, host_name, node_name, vm_id, vm_type -): + hass: HomeAssistant, + proxmox: ProxmoxAPI, + host_name: str, + node_name: str, + vm_id: int, + vm_type: int, +) -> DataUpdateCoordinator[dict[str, Any] | None]: """Create and return a DataUpdateCoordinator for a vm/container.""" - async def async_update_data(): + async def async_update_data() -> dict[str, Any] | None: """Call the api and handle the response.""" - def poll_api(): + def poll_api() -> dict[str, Any] | None: """Call the api.""" vm_status = call_api_container_vm(proxmox, node_name, vm_id, vm_type) return vm_status @@ -216,7 +222,7 @@ def create_coordinator_container_vm( ) -def parse_api_container_vm(status): +def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]: """Get the container or vm api data and return it formatted in a dictionary. It is implemented in this way to allow for more data to be added for sensors @@ -226,7 +232,12 @@ def parse_api_container_vm(status): return {"status": status["status"], "name": status["name"]} -def call_api_container_vm(proxmox, node_name, vm_id, machine_type): +def call_api_container_vm( + proxmox: ProxmoxAPI, + node_name: str, + vm_id: int, + machine_type: int, +) -> dict[str, Any] | None: """Make proper api calls.""" status = None @@ -247,12 +258,12 @@ class ProxmoxEntity(CoordinatorEntity): def __init__( self, coordinator: DataUpdateCoordinator, - unique_id, - name, - icon, - host_name, - node_name, - vm_id=None, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int | None = None, ) -> None: """Initialize the Proxmox entity.""" super().__init__(coordinator) @@ -292,7 +303,17 @@ class ProxmoxEntity(CoordinatorEntity): class ProxmoxClient: """A wrapper for the proxmoxer ProxmoxAPI client.""" - def __init__(self, host, port, user, realm, password, verify_ssl): + _proxmox: ProxmoxAPI + + def __init__( + self, + host: str, + port: int, + user: str, + realm: str, + password: str, + verify_ssl: bool, + ) -> None: """Initialize the ProxmoxClient.""" self._host = host @@ -302,10 +323,7 @@ class ProxmoxClient: self._password = password self._verify_ssl = verify_ssl - self._proxmox = None - self._connection_start_time = None - - def build_client(self): + def build_client(self) -> None: """Construct the ProxmoxAPI client. Allows inserting the realm within the `user` value. @@ -324,6 +342,6 @@ class ProxmoxClient: verify_ssl=self._verify_ssl, ) - def get_api_client(self): + def get_api_client(self) -> ProxmoxAPI: """Return the ProxmoxAPI client.""" return self._proxmox diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 9bb78d46ea73..828c81911480 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -51,7 +51,13 @@ async def async_setup_platform( add_entities(sensors) -def create_binary_sensor(coordinator, host_name, node_name, vm_id, name): +def create_binary_sensor( + coordinator, + host_name: str, + node_name: str, + vm_id: int, + name: str, +) -> ProxmoxBinarySensor: """Create a binary sensor based on the given data.""" return ProxmoxBinarySensor( coordinator=coordinator, @@ -72,12 +78,12 @@ class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): def __init__( self, coordinator: DataUpdateCoordinator, - unique_id, - name, - icon, - host_name, - node_name, - vm_id, + unique_id: str, + name: str, + icon: str, + host_name: str, + node_name: str, + vm_id: int, ) -> None: """Create the binary sensor for vms or containers.""" super().__init__( @@ -85,7 +91,7 @@ class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return the state of the binary sensor.""" if (data := self.coordinator.data) is None: return None From e0424c83228631eaa0d72f14655a035305f95bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jens=20=C3=98stergaard=20Nielsen?= Date: Tue, 28 Mar 2023 10:23:00 +0200 Subject: [PATCH 0863/1058] Use shorthand attributes in IHC (#90350) * typings to make linter happy * Moving device_class and native_value to init * remove is_on and use attr_is_on * Use try_parse_enum for sensor type * Remove not needed sensor_type Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update homeassistant/components/ihc/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/ihc/binary_sensor.py | 23 ++++++---------- homeassistant/components/ihc/sensor.py | 26 +++---------------- homeassistant/components/ihc/switch.py | 8 +----- 3 files changed, 13 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/ihc/binary_sensor.py b/homeassistant/components/ihc/binary_sensor.py index 48035d27a4d1..badf0f4e92f2 100644 --- a/homeassistant/components/ihc/binary_sensor.py +++ b/homeassistant/components/ihc/binary_sensor.py @@ -3,11 +3,15 @@ from __future__ import annotations from ihcsdk.ihccontroller import IHCController -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.enum import try_parse_enum from .const import CONF_INVERTING, DOMAIN, IHC_CONTROLLER from .ihcdevice import IHCDevice @@ -62,24 +66,13 @@ class IHCBinarySensor(IHCDevice, BinarySensorEntity): ) -> None: """Initialize the IHC binary sensor.""" super().__init__(ihc_controller, controller_id, name, ihc_id, product) - self._state = None - self._sensor_type = sensor_type + self._attr_device_class = try_parse_enum(BinarySensorDeviceClass, sensor_type) self.inverting = inverting - @property - def device_class(self): - """Return the class of this sensor.""" - return self._sensor_type - - @property - def is_on(self): - """Return true if the binary sensor is on/open.""" - return self._state - def on_ihc_change(self, ihc_id, value): """IHC resource has changed.""" if self.inverting: - self._state = not value + self._attr_is_on = not value else: - self._state = value + self._attr_is_on = value self.schedule_update_ha_state() diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index d3c38687caa7..c1210a358d61 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -51,29 +51,11 @@ class IHCSensor(IHCDevice, SensorEntity): ) -> None: """Initialize the IHC sensor.""" super().__init__(ihc_controller, controller_id, name, ihc_id, product) - self._state = None - self._unit_of_measurement = unit - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return ( - SensorDeviceClass.TEMPERATURE - if self._unit_of_measurement in TEMPERATURE_UNITS - else None - ) - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement + self._attr_native_unit_of_measurement = unit + if unit in TEMPERATURE_UNITS: + self._attr_device_class = SensorDeviceClass.TEMPERATURE def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" - self._state = value + self._attr_native_value = value self.schedule_update_ha_state() diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py index 8e8edb0b7f7a..d4593dad5703 100644 --- a/homeassistant/components/ihc/switch.py +++ b/homeassistant/components/ihc/switch.py @@ -59,12 +59,6 @@ class IHCSwitch(IHCDevice, SwitchEntity): super().__init__(ihc_controller, controller_id, name, ihc_id, product) self._ihc_off_id = ihc_off_id self._ihc_on_id = ihc_on_id - self._state = False - - @property - def is_on(self): - """Return true if switch is on.""" - return self._state async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -82,5 +76,5 @@ class IHCSwitch(IHCDevice, SwitchEntity): def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" - self._state = value + self._attr_is_on = value self.schedule_update_ha_state() From 2fd872b253c112765fd1ac367ffa35871b05d79e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Mar 2023 23:02:08 -1000 Subject: [PATCH 0864/1058] Speed up profiler lru test (#90395) --- tests/components/profiler/test_init.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 2c283463b620..af642c779e1c 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -10,6 +10,7 @@ import py import pytest from homeassistant.components.profiler import ( + _LRU_CACHE_WRAPPER_OBJECT, CONF_SECONDS, SERVICE_DUMP_LOG_OBJECTS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, @@ -253,10 +254,15 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) domain_data = DomainData() assert hass.services.has_service(DOMAIN, SERVICE_LRU_STATS) - await hass.services.async_call(DOMAIN, SERVICE_LRU_STATS, blocking=True) + def _mock_by_type(type_): + if type_ == _LRU_CACHE_WRAPPER_OBJECT: + return [_dummy_test_lru_stats] + return [domain_data] + + with patch("objgraph.by_type", side_effect=_mock_by_type): + await hass.services.async_call(DOMAIN, SERVICE_LRU_STATS, blocking=True) assert "DomainData" in caplog.text assert "(0, 0)" in caplog.text assert "_dummy_test_lru_stats" in caplog.text assert "CacheInfo" in caplog.text - del domain_data From 23a1a8075c9f31f930dee0607f3e5b9b1f9802b5 Mon Sep 17 00:00:00 2001 From: Pascal Reeb Date: Tue, 28 Mar 2023 11:28:04 +0200 Subject: [PATCH 0865/1058] Add callback support to nuki (#88346) * feat(nuki): add callback support * fix(nuki): add webhook_enabled to tests * remove callback choice, add repair if it's https * black * fix(nuki): implemented feedback from pvizeli and frenck * remove unneded test change * remove issue_registry and http check * remove unneded response * add await to executor_job --- homeassistant/components/nuki/__init__.py | 111 +++++++++++++++++++- homeassistant/components/nuki/manifest.json | 1 + 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 9504d38c932b..74245d30d4a7 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,9 +3,11 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta +from http import HTTPStatus import logging from typing import Generic, TypeVar +from aiohttp import web import async_timeout from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki.bridge import InvalidCredentialsException @@ -13,10 +15,18 @@ from pynuki.device import NukiDevice from requests.exceptions import RequestException from homeassistant import exceptions +from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -46,6 +56,29 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp return bridge.locks, bridge.openers +def _register_webhook(bridge: NukiBridge, entry_id: str, url: str) -> bool: + # Register HA URL as webhook if not already + callbacks = bridge.callback_list() + for item in callbacks["callbacks"]: + if entry_id in item["url"]: + if item["url"] == url: + return True + bridge.callback_remove(item["id"]) + + if bridge.callback_add(url)["success"]: + return True + + return False + + +def _remove_webhook(bridge: NukiBridge, entry_id: str) -> None: + # Remove webhook if set + callbacks = bridge.callback_list() + for item in callbacks["callbacks"]: + if entry_id in item["url"]: + bridge.callback_remove(item["id"]) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Nuki entry.""" @@ -88,6 +121,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sw_version=info["versions"]["firmwareVersion"], ) + async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request + ) -> web.Response: + """Handle webhook callback.""" + try: + data = await request.json() + except ValueError: + return web.Response(status=HTTPStatus.BAD_REQUEST) + + locks = hass.data[DOMAIN][entry.entry_id][DATA_LOCKS] + openers = hass.data[DOMAIN][entry.entry_id][DATA_OPENERS] + + devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]] + if len(devices) == 1: + devices[0].update_from_callback(data) + + coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator.async_set_updated_data(None) + + return web.Response(status=HTTPStatus.OK) + + webhook.async_register( + hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True + ) + + async def _stop_nuki(_: Event): + """Stop and remove the Nuki webhook.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, bridge, entry.entry_id + ) + except InvalidCredentialsException as err: + raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) + ) + + webhook_url = webhook.async_generate_path(entry.entry_id) + hass_url = get_url( + hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False + ) + url = f"{hass_url}{webhook_url}" + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _register_webhook, bridge, entry.entry_id, url + ) + except InvalidCredentialsException as err: + raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + except RequestException as err: + raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + coordinator = NukiCoordinator(hass, bridge, locks, openers) hass.data[DOMAIN][entry.entry_id] = { @@ -107,6 +197,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Nuki entry.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, + hass.data[DOMAIN][entry.entry_id][DATA_BRIDGE], + entry.entry_id, + ) + except InvalidCredentialsException as err: + raise UpdateFailed( + f"Unable to remove callback. Invalid credentials for Bridge: {err}" + ) from err + except RequestException as err: + raise UpdateFailed( + f"Unable to remove callback. Error communicating with Bridge: {err}" + ) from err + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index e6b741d44293..8b87816fb7d0 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -3,6 +3,7 @@ "name": "Nuki", "codeowners": ["@pschmitt", "@pvizeli", "@pree"], "config_flow": true, + "dependencies": ["webhook"], "dhcp": [ { "hostname": "nuki_bridge_*" From e617bfb1bb2436955ada65cd5c9b5589ca57b7f4 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Tue, 28 Mar 2023 05:51:35 -0400 Subject: [PATCH 0866/1058] Display unit of elevation in met config flow (#88283) * display unit of elevation in met config flow Co-authored-by: lijake8 Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> * use NumberSelector for met config flow * met remove unused is_metric param --------- Signed-off-by: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Co-authored-by: lijake8 --- homeassistant/components/met/__init__.py | 20 ++------------ homeassistant/components/met/config_flow.py | 29 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index c95c3abe05e6..c676f15336ec 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -18,15 +18,12 @@ from homeassistant.const import ( CONF_LONGITUDE, EVENT_CORE_CONFIG_UPDATE, Platform, - UnitOfLength, ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError 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 ( CONF_TRACK_HOME, @@ -102,9 +99,7 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize global Met data updater.""" self._unsub_track_home: Callable[[], None] | None = None - self.weather = MetWeatherData( - hass, config_entry.data, hass.config.units is METRIC_SYSTEM - ) + self.weather = MetWeatherData(hass, config_entry.data) self.weather.set_coordinates() update_interval = timedelta(minutes=randrange(55, 65)) @@ -142,13 +137,10 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator["MetWeatherData"]): class MetWeatherData: """Keep data for Met.no weather entities.""" - def __init__( - self, hass: HomeAssistant, config: MappingProxyType[str, Any], is_metric: bool - ) -> None: + def __init__(self, hass: HomeAssistant, config: MappingProxyType[str, Any]) -> None: """Initialise the weather entity data.""" self.hass = hass self._config = config - self._is_metric = is_metric self._weather_data: metno.MetWeatherData self.current_weather_data: dict = {} self.daily_forecast: list[dict] = [] @@ -165,14 +157,6 @@ class MetWeatherData: latitude = self._config[CONF_LATITUDE] longitude = self._config[CONF_LONGITUDE] elevation = self._config[CONF_ELEVATION] - if not self._is_metric: - elevation = int( - round( - DistanceConverter.convert( - elevation, UnitOfLength.FEET, UnitOfLength.METERS - ) - ) - ) coordinates = { "lat": str(latitude), diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 453c0a9cee80..d8cb31077c21 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -6,10 +6,21 @@ 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.const import ( + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + UnitOfLength, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, +) from .const import ( CONF_TRACK_HOME, @@ -47,7 +58,14 @@ def _get_data_schema( vol.Required( CONF_LONGITUDE, default=hass.config.longitude ): cv.longitude, - vol.Required(CONF_ELEVATION, default=hass.config.elevation): int, + vol.Required( + CONF_ELEVATION, default=hass.config.elevation + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) # Not tracking home, default values come from config entry @@ -62,7 +80,12 @@ def _get_data_schema( ): cv.longitude, vol.Required( CONF_ELEVATION, default=config_entry.data.get(CONF_ELEVATION) - ): int, + ): NumberSelector( + NumberSelectorConfig( + mode=NumberSelectorMode.BOX, + unit_of_measurement=UnitOfLength.METERS, + ) + ), } ) From 96dae587a9822ef3ee3e5c57905d3daa5ab812fb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Mar 2023 11:54:16 +0200 Subject: [PATCH 0867/1058] Fix ridwell tests (#90401) --- tests/components/ridwell/conftest.py | 3 --- tests/components/ridwell/test_diagnostics.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/components/ridwell/conftest.py b/tests/components/ridwell/conftest.py index 57d485d42813..87ca00c37c30 100644 --- a/tests/components/ridwell/conftest.py +++ b/tests/components/ridwell/conftest.py @@ -3,7 +3,6 @@ from datetime import date from unittest.mock import AsyncMock, Mock, patch from aioridwell.model import EventState, RidwellPickup, RidwellPickupEvent -from freezegun import freeze_time import pytest from homeassistant.components.ridwell.const import DOMAIN @@ -80,8 +79,6 @@ async def mock_aioridwell_fixture(hass, client, config): ), patch( "homeassistant.components.ridwell.coordinator.async_get_client", return_value=client, - ), freeze_time( - "2022-01-01" ): yield diff --git a/tests/components/ridwell/test_diagnostics.py b/tests/components/ridwell/test_diagnostics.py index caac4880417e..e73b352f3d98 100644 --- a/tests/components/ridwell/test_diagnostics.py +++ b/tests/components/ridwell/test_diagnostics.py @@ -32,7 +32,7 @@ async def test_entry_diagnostics( "_async_request": None, "event_id": "event_123", "pickup_date": { - "__type": "", + "__type": "", "isoformat": "2022-01-24", }, "pickups": [ From 6fbdcac3232d4fd6d3c0170eb99691f18b132e58 Mon Sep 17 00:00:00 2001 From: BNolet Date: Tue, 28 Mar 2023 06:20:54 -0400 Subject: [PATCH 0868/1058] Fix setting color + brightness of Tuya lights (#88470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Check if changing TO a color mode Changing brightness alone does not change work mode, but changing brightness with a color value will keep the light in white mode. By verifying the new state has color or not, rather than the existing state being in color work mode, the light will change to color correctly. Tuya interprets HSV as including the brightness in the (v) value (which is generally what that's used for when setting HSV values). The brightness value given by Home Assistant is still used in this case. * Fix brightness-only turning colour mode to white This will take into account the case where brightness is the only parameter for both the case of colour mode and white mode. Tests passed after this change: * Brightness only (colour mode) ✅ * Brightness only (white mode) ✅ * Colour only (colour mode) ✅ * Colour only (white mode) ✅ * Colour temp only (colour mode) ✅ * Colour temp only (white mode) ✅ * Colour + brightness (colour mode) ✅ * Colour + brightness (white mode) ✅ * Colour temp + brightness (colour mode) ✅ * Colour temp + brightness (white mode) ✅ * Fix code formatting --- homeassistant/components/tuya/light.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 3546e4545136..959a1834f8da 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -499,9 +499,14 @@ class TuyaLightEntity(TuyaEntity, LightEntity): ), }, ] - elif self._color_data_type and ( + + if self._color_data_type and ( ATTR_HS_COLOR in kwargs - or (ATTR_BRIGHTNESS in kwargs and self.color_mode == ColorMode.HS) + or ( + ATTR_BRIGHTNESS in kwargs + and self.color_mode == ColorMode.HS + and ATTR_COLOR_TEMP not in kwargs + ) ): if self._color_mode_dpcode: commands += [ @@ -542,11 +547,7 @@ class TuyaLightEntity(TuyaEntity, LightEntity): }, ] - if ( - ATTR_BRIGHTNESS in kwargs - and self.color_mode != ColorMode.HS - and self._brightness - ): + elif ATTR_BRIGHTNESS in kwargs and self._brightness: brightness = kwargs[ATTR_BRIGHTNESS] # If there is a min/max value, the brightness is actually limited. From be5714e3fd6f6f3ba3281a55a3b7d310943dce3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 00:24:12 -1000 Subject: [PATCH 0869/1058] Use slots for recorder tasks to reduce memory (#90387) --- homeassistant/components/recorder/tasks.py | 59 +++++++++++----------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index ef1188570597..dfa6ce32d259 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -26,7 +26,8 @@ if TYPE_CHECKING: from .core import Recorder -class RecorderTask(abc.ABC): +@dataclass(slots=True) +class RecorderTask: """ABC for recorder tasks.""" commit_before = True @@ -36,7 +37,7 @@ class RecorderTask(abc.ABC): """Handle the task.""" -@dataclass +@dataclass(slots=True) class ChangeStatisticsUnitTask(RecorderTask): """Object to store statistics_id and unit to convert unit of statistics.""" @@ -54,7 +55,7 @@ class ChangeStatisticsUnitTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class ClearStatisticsTask(RecorderTask): """Object to store statistics_ids which for which to remove statistics.""" @@ -65,7 +66,7 @@ class ClearStatisticsTask(RecorderTask): statistics.clear_statistics(instance, self.statistic_ids) -@dataclass +@dataclass(slots=True) class UpdateStatisticsMetadataTask(RecorderTask): """Object to store statistics_id and unit for update of statistics metadata.""" @@ -83,7 +84,7 @@ class UpdateStatisticsMetadataTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class UpdateStatesMetadataTask(RecorderTask): """Task to update states metadata.""" @@ -99,7 +100,7 @@ class UpdateStatesMetadataTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class PurgeTask(RecorderTask): """Object to store information about purge task.""" @@ -125,7 +126,7 @@ class PurgeTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class PurgeEntitiesTask(RecorderTask): """Object to store entity information about purge task.""" @@ -140,7 +141,7 @@ class PurgeEntitiesTask(RecorderTask): instance.queue_task(PurgeEntitiesTask(self.entity_filter, self.purge_before)) -@dataclass +@dataclass(slots=True) class PerodicCleanupTask(RecorderTask): """An object to insert into the recorder to trigger cleanup tasks. @@ -152,7 +153,7 @@ class PerodicCleanupTask(RecorderTask): periodic_db_cleanups(instance) -@dataclass +@dataclass(slots=True) class StatisticsTask(RecorderTask): """An object to insert into the recorder queue to run a statistics task.""" @@ -167,7 +168,7 @@ class StatisticsTask(RecorderTask): instance.queue_task(StatisticsTask(self.start, self.fire_events)) -@dataclass +@dataclass(slots=True) class CompileMissingStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run a compile missing statistics.""" @@ -179,7 +180,7 @@ class CompileMissingStatisticsTask(RecorderTask): instance.queue_task(CompileMissingStatisticsTask()) -@dataclass +@dataclass(slots=True) class ImportStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an import statistics task.""" @@ -199,7 +200,7 @@ class ImportStatisticsTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class AdjustStatisticsTask(RecorderTask): """An object to insert into the recorder queue to run an adjust statistics task.""" @@ -229,7 +230,7 @@ class AdjustStatisticsTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class WaitTask(RecorderTask): """An object to insert into the recorder queue. @@ -243,7 +244,7 @@ class WaitTask(RecorderTask): instance._queue_watch.set() # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class DatabaseLockTask(RecorderTask): """An object to insert into the recorder queue to prevent writes to the database.""" @@ -256,7 +257,7 @@ class DatabaseLockTask(RecorderTask): instance._lock_database(self) # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class StopTask(RecorderTask): """An object to insert into the recorder queue to stop the event handler.""" @@ -267,7 +268,7 @@ class StopTask(RecorderTask): instance.stop_requested = True -@dataclass +@dataclass(slots=True) class EventTask(RecorderTask): """An event to be processed.""" @@ -280,7 +281,7 @@ class EventTask(RecorderTask): instance._process_one_event(self.event) -@dataclass +@dataclass(slots=True) class KeepAliveTask(RecorderTask): """A keep alive to be sent.""" @@ -292,7 +293,7 @@ class KeepAliveTask(RecorderTask): instance._send_keep_alive() -@dataclass +@dataclass(slots=True) class CommitTask(RecorderTask): """Commit the event session.""" @@ -304,7 +305,7 @@ class CommitTask(RecorderTask): instance._commit_event_session_or_retry() -@dataclass +@dataclass(slots=True) class AddRecorderPlatformTask(RecorderTask): """Add a recorder platform.""" @@ -321,7 +322,7 @@ class AddRecorderPlatformTask(RecorderTask): platforms[domain] = platform -@dataclass +@dataclass(slots=True) class SynchronizeTask(RecorderTask): """Ensure all pending data has been committed.""" @@ -335,7 +336,7 @@ class SynchronizeTask(RecorderTask): instance.hass.loop.call_soon_threadsafe(self.event.set) -@dataclass +@dataclass(slots=True) class PostSchemaMigrationTask(RecorderTask): """Post migration task to update schema.""" @@ -349,7 +350,7 @@ class PostSchemaMigrationTask(RecorderTask): ) -@dataclass +@dataclass(slots=True) class StatisticsTimestampMigrationCleanupTask(RecorderTask): """An object to insert into the recorder queue to run a statistics migration cleanup task.""" @@ -360,7 +361,7 @@ class StatisticsTimestampMigrationCleanupTask(RecorderTask): instance.queue_task(StatisticsTimestampMigrationCleanupTask()) -@dataclass +@dataclass(slots=True) class AdjustLRUSizeTask(RecorderTask): """An object to insert into the recorder queue to adjust the LRU size.""" @@ -371,7 +372,7 @@ class AdjustLRUSizeTask(RecorderTask): instance._adjust_lru_size() # pylint: disable=[protected-access] -@dataclass +@dataclass(slots=True) class StatesContextIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate states context ids.""" @@ -386,7 +387,7 @@ class StatesContextIDMigrationTask(RecorderTask): instance.queue_task(StatesContextIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EventsContextIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate events context ids.""" @@ -401,7 +402,7 @@ class EventsContextIDMigrationTask(RecorderTask): instance.queue_task(EventsContextIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EventTypeIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate event type ids.""" @@ -417,7 +418,7 @@ class EventTypeIDMigrationTask(RecorderTask): instance.queue_task(EventTypeIDMigrationTask()) -@dataclass +@dataclass(slots=True) class EntityIDMigrationTask(RecorderTask): """An object to insert into the recorder queue to migrate entity_ids to StatesMeta.""" @@ -440,7 +441,7 @@ class EntityIDMigrationTask(RecorderTask): instance.queue_task(EntityIDPostMigrationTask()) -@dataclass +@dataclass(slots=True) class EntityIDPostMigrationTask(RecorderTask): """An object to insert into the recorder queue to cleanup after entity_ids migration.""" @@ -453,7 +454,7 @@ class EntityIDPostMigrationTask(RecorderTask): instance.queue_task(EntityIDPostMigrationTask()) -@dataclass +@dataclass(slots=True) class EventIdMigrationTask(RecorderTask): """An object to insert into the recorder queue to cleanup legacy event_ids in the states table. From ae41547b73e4d1354ec1c43dd0021531aeff564c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Mar 2023 03:25:44 -0700 Subject: [PATCH 0870/1058] Update calendar to always request start/end dates in local time rather than UTC (#90386) --- homeassistant/components/calendar/__init__.py | 2 +- homeassistant/components/google/calendar.py | 4 +-- .../components/local_calendar/calendar.py | 11 +++---- .../local_calendar/test_calendar.py | 33 ++++++++++++++++--- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 9af324465664..0b1c37cea5fe 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -523,7 +523,7 @@ class CalendarEventView(http.HomeAssistantView): try: calendar_event_list = await entity.async_get_events( - request.app["hass"], start_date, end_date + request.app["hass"], dt.as_local(start_date), dt.as_local(end_date) ) except HomeAssistantError as err: _LOGGER.debug("Error reading events: %s", err) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 1e1072940add..363b75c2c54b 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -283,8 +283,8 @@ class CalendarSyncUpdateCoordinator(DataUpdateCoordinator[Timeline]): "Unable to get events: Sync from server has not completed" ) return self.data.overlapping( - dt_util.as_local(start_date), - dt_util.as_local(end_date), + start_date, + end_date, ) @property diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 718c65ffce22..4b6d9444fd89 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -85,17 +85,16 @@ class LocalCalendarEntity(CalendarEntity): self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - events = self._calendar.timeline_tz(dt_util.DEFAULT_TIME_ZONE).overlapping( - dt_util.as_local(start_date), - dt_util.as_local(end_date), + events = self._calendar.timeline_tz(start_date.tzinfo).overlapping( + start_date, + end_date, ) return [_get_calendar_event(event) for event in events] async def async_update(self) -> None: """Update entity state with the next upcoming event.""" - events = self._calendar.timeline_tz(dt_util.DEFAULT_TIME_ZONE).active_after( - dt_util.now() - ) + now = dt_util.now() + events = self._calendar.timeline_tz(now.tzinfo).active_after(now) if event := next(events, None): self._event = _get_calendar_event(event) else: diff --git a/tests/components/local_calendar/test_calendar.py b/tests/components/local_calendar/test_calendar.py index 6bdb58cf65d0..a2f13ea289d5 100644 --- a/tests/components/local_calendar/test_calendar.py +++ b/tests/components/local_calendar/test_calendar.py @@ -37,10 +37,27 @@ async def test_empty_calendar( } +@pytest.mark.parametrize( + ("dtstart", "dtend"), + [ + ("1997-07-14T18:00:00+01:00", "1997-07-15T05:00:00+01:00"), + ("1997-07-14T17:00:00+00:00", "1997-07-15T04:00:00+00:00"), + ("1997-07-14T11:00:00-06:00", "1997-07-14T22:00:00-06:00"), + ("1997-07-14T10:00:00-07:00", "1997-07-14T21:00:00-07:00"), + ], +) async def test_api_date_time_event( - ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn + ws_client: ClientFixture, + setup_integration: None, + get_events: GetEventsFn, + dtstart: str, + dtend: str, ) -> None: - """Test an event with a start/end date time.""" + """Test an event with a start/end date time. + + Events created in various timezones are ultimately returned relative + to local home assistant timezone. + """ client = await ws_client() await client.cmd_result( "create", @@ -48,8 +65,8 @@ async def test_api_date_time_event( "entity_id": TEST_ENTITY, "event": { "summary": "Bastille Day Party", - "dtstart": "1997-07-14T17:00:00+00:00", - "dtend": "1997-07-15T04:00:00+00:00", + "dtstart": dtstart, + "dtend": dtend, }, }, ) @@ -63,6 +80,8 @@ async def test_api_date_time_event( } ] + # Query events in UTC + # Time range before event events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T16:00:00Z") assert len(events) == 0 @@ -77,6 +96,12 @@ async def test_api_date_time_event( events = await get_events("1997-07-15T03:00:00Z", "1997-07-15T06:00:00Z") assert len(events) == 1 + # Query events overlapping with start and end but in another timezone + events = await get_events("1997-07-12T23:00:00-01:00", "1997-07-14T17:00:00-01:00") + assert len(events) == 1 + events = await get_events("1997-07-15T02:00:00-01:00", "1997-07-15T05:00:00-01:00") + assert len(events) == 1 + async def test_api_date_event( ws_client: ClientFixture, setup_integration: None, get_events: GetEventsFn From 3c3860c923b34cdd7b5c8442a0b9e9daefb14ba6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 12:34:25 +0200 Subject: [PATCH 0871/1058] Make OTBR use same channel as ZHA (#88546) --- .../silabs_multiprotocol_addon.py | 8 +++ homeassistant/components/otbr/config_flow.py | 23 ++++---- homeassistant/components/otbr/manifest.json | 4 +- homeassistant/components/otbr/util.py | 43 ++++++++++++++ .../components/otbr/websocket_api.py | 19 +++--- .../test_silabs_multiprotocol_addon.py | 11 ++++ tests/components/otbr/test_config_flow.py | 13 ++++- tests/components/otbr/test_util.py | 58 +++++++++++++++++++ tests/components/otbr/test_websocket_api.py | 26 ++++++--- 9 files changed, 171 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/otbr/util.py create mode 100644 tests/components/otbr/test_util.py diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 41f16462cd8c..ff2bf9138f53 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -8,6 +8,7 @@ import logging from typing import Any import voluptuous as vol +import yarl from homeassistant import config_entries from homeassistant.components.hassio import ( @@ -74,6 +75,13 @@ def get_zigbee_socket() -> str: return f"socket://{hostname}:9999" +def is_multiprotocol_url(url: str) -> bool: + """Return if the URL points at the Multiprotocol add-on.""" + parsed = yarl.URL(url) + hostname = hostname_from_addon_slug(SILABS_MULTIPROTOCOL_ADDON_SLUG) + return parsed.host == hostname + + class BaseMultiPanFlow(FlowHandler, ABC): """Support configuring the Silicon Labs Multiprotocol add-on.""" diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 4247d5dbd653..434b9026ae28 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN +from .util import get_allowed_channel _LOGGER = logging.getLogger(__name__) @@ -27,13 +28,12 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _connect_and_create_dataset(self, url: str) -> None: - """Connect to the OTBR and create a dataset if it doesn't have one.""" - api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10) + async def _connect_and_set_dataset(self, otbr_url: str) -> None: + """Connect to the OTBR and create or apply a dataset if it doesn't have one.""" + api = python_otbr_api.OTBR(otbr_url, async_get_clientsession(self.hass), 10) if await api.get_active_dataset_tlvs() is None: - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL + allowed_channel = await get_allowed_channel(self.hass, otbr_url) + thread_dataset_channel = None thread_dataset_tlv = await async_get_preferred_dataset(self.hass) if thread_dataset_tlv: @@ -41,7 +41,9 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): thread_dataset_channel = int(channel_str, base=16) - if thread_dataset_tlv is not None and zha_channel == thread_dataset_channel: + if thread_dataset_tlv is not None and ( + not allowed_channel or allowed_channel == thread_dataset_channel + ): await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv)) else: _LOGGER.debug( @@ -49,7 +51,8 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): ) await api.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, + network_name="home-assistant", ) ) await api.set_enabled(True) @@ -66,7 +69,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: url = user_input[CONF_URL] try: - await self._connect_and_create_dataset(url) + await self._connect_and_set_dataset(url) except ( python_otbr_api.OTBRError, aiohttp.ClientError, @@ -108,7 +111,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") try: - await self._connect_and_create_dataset(url) + await self._connect_and_set_dataset(url) except python_otbr_api.OTBRError as exc: _LOGGER.warning("Failed to communicate with OTBR@%s: %s", url, exc) return self.async_abort(reason="unknown") diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 2590e92210f8..8e9050ca9f41 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -1,10 +1,10 @@ { "domain": "otbr", "name": "Open Thread Border Router", - "after_dependencies": ["hassio"], + "after_dependencies": ["hassio", "zha"], "codeowners": ["@home-assistant/core"], "config_flow": true, - "dependencies": ["thread"], + "dependencies": ["homeassistant_hardware", "thread"], "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py new file mode 100644 index 000000000000..b1a3ee11b82c --- /dev/null +++ b/homeassistant/components/otbr/util.py @@ -0,0 +1,43 @@ +"""Utility functions for the Open Thread Border Router integration.""" +from __future__ import annotations + +import contextlib + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.components.zha import api as zha_api +from homeassistant.core import HomeAssistant + + +def _get_zha_url(hass: HomeAssistant) -> str | None: + """Get ZHA radio path, or None if there's no ZHA config entry.""" + with contextlib.suppress(ValueError): + return zha_api.async_get_radio_path(hass) + return None + + +async def _get_zha_channel(hass: HomeAssistant) -> int | None: + """Get ZHA channel, or None if there's no ZHA config entry.""" + zha_network_settings: zha_api.NetworkBackup | None + with contextlib.suppress(ValueError): + zha_network_settings = await zha_api.async_get_network_settings(hass) + if not zha_network_settings: + return None + channel: int = zha_network_settings.network_info.channel + # ZHA uses channel 0 when no channel is set + return channel or None + + +async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: + """Return the allowed channel, or None if there's no restriction.""" + if not is_multiprotocol_url(otbr_url): + # The OTBR is not sharing the radio, no restriction + return None + + zha_url = _get_zha_url(hass) + if not zha_url or not is_multiprotocol_url(zha_url): + # ZHA is not configured or not sharing the radio with this OTBR, no restriction + return None + + return await _get_zha_channel(hass) diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index aa8c1dd2dd99..cd4f8875e7ba 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from .const import DEFAULT_CHANNEL, DOMAIN +from .util import get_allowed_channel if TYPE_CHECKING: from . import OTBRData @@ -72,11 +73,8 @@ async def websocket_create_network( connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") return - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL - data: OTBRData = hass.data[DOMAIN] + channel = await get_allowed_channel(hass, data.url) or DEFAULT_CHANNEL try: await data.set_enabled(False) @@ -87,7 +85,7 @@ async def websocket_create_network( try: await data.create_active_dataset( python_otbr_api.OperationalDataSet( - channel=zha_channel, network_name="home-assistant" + channel=channel, network_name="home-assistant" ) ) except HomeAssistantError as exc: @@ -139,21 +137,18 @@ async def websocket_set_network( if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): thread_dataset_channel = int(channel_str, base=16) - # We currently have no way to know which channel zha is using, assume it's - # the default - zha_channel = DEFAULT_CHANNEL + data: OTBRData = hass.data[DOMAIN] + allowed_channel = await get_allowed_channel(hass, data.url) - if thread_dataset_channel != zha_channel: + if allowed_channel and thread_dataset_channel != allowed_channel: connection.send_error( msg["id"], "channel_conflict", f"Can't connect to network on channel {thread_dataset_channel}, ZHA is " - f"using channel {zha_channel}", + f"using channel {allowed_channel}", ) return - data: OTBRData = hass.data[DOMAIN] - try: await data.set_enabled(False) except HomeAssistantError as exc: diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 424e4126e05f..a195899136dc 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -795,3 +795,14 @@ 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.ABORT assert result["reason"] == "zha_migration_failed" + + +def test_is_multiprotocol_url() -> None: + """Test is_multiprotocol_url.""" + assert silabs_multiprotocol_addon.is_multiprotocol_url( + "socket://core-silabs-multiprotocol:9999" + ) + assert silabs_multiprotocol_addon.is_multiprotocol_url( + "http://core-silabs-multiprotocol:8081" + ) + assert not silabs_multiprotocol_addon.is_multiprotocol_url("/dev/ttyAMA1") diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index ae49c63002a6..b788c93610d1 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from http import HTTPStatus from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch import aiohttp import pytest @@ -320,13 +320,22 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + networksettings = Mock() + networksettings.network_info.channel = 15 + with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", return_value=DATASET_CH16.hex(), ), patch( "homeassistant.components.otbr.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=networksettings, + ): result = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA ) diff --git a/tests/components/otbr/test_util.py b/tests/components/otbr/test_util.py new file mode 100644 index 000000000000..af5306b3581b --- /dev/null +++ b/tests/components/otbr/test_util.py @@ -0,0 +1,58 @@ +"""Test OTBR Utility functions.""" +from unittest.mock import Mock, patch + +from homeassistant.components import otbr +from homeassistant.core import HomeAssistant + +OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" +OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" + + +async def test_get_allowed_channel(hass: HomeAssistant) -> None: + """Test get_allowed_channel.""" + + zha_networksettings = Mock() + zha_networksettings.network_info.channel = 15 + + # OTBR multipan + No ZHA -> no restriction + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + + # OTBR multipan + ZHA multipan empty settings -> no restriction + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=None, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + + # OTBR multipan + ZHA not multipan using channel 15 -> no restriction + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="/dev/ttyAMA1", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=zha_networksettings, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + + # OTBR multipan + ZHA multipan using channel 15 -> 15 + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=zha_networksettings, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) == 15 + + # OTBR not multipan + ZHA multipan using channel 15 -> no restriction + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=zha_networksettings, + ): + assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 844216225704..e6f492f5e5ff 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,5 +1,5 @@ """Test OTBR Websocket API.""" -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest import python_otbr_api @@ -283,14 +283,24 @@ async def test_set_network_channel_conflict( dataset_store = await thread.dataset_store.async_get_store(hass) dataset_id = list(dataset_store.datasets)[0] - await websocket_client.send_json_auto_id( - { - "type": "otbr/set_network", - "dataset_id": dataset_id, - } - ) + networksettings = Mock() + networksettings.network_info.channel = 15 - msg = await websocket_client.receive_json() + with patch( + "homeassistant.components.otbr.util.zha_api.async_get_radio_path", + return_value="socket://core-silabs-multiprotocol:9999", + ), patch( + "homeassistant.components.otbr.util.zha_api.async_get_network_settings", + return_value=networksettings, + ): + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) + + msg = await websocket_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "channel_conflict" From 1c465b5ad07e5b9ab589d50297899d2191af3308 Mon Sep 17 00:00:00 2001 From: Olivier Ouellet <85790609+olivierouellet@users.noreply.github.com> Date: Tue, 28 Mar 2023 06:42:31 -0400 Subject: [PATCH 0872/1058] Add encoding configuration setting to REST and Scape (#90254) * Create new config parameter for default character encoding if no character encoding is declared * Changes suggested by gjohansson-ST * Added config flow for scape * Removed "character" * Change to create_async_httpx_client * Remove CONF_ENCODING from Scrape SENSOR_SCHEMA * Debug scrape test --- homeassistant/components/rest/__init__.py | 23 ++++++++++++++++--- homeassistant/components/rest/const.py | 2 ++ homeassistant/components/rest/data.py | 8 ++++--- homeassistant/components/rest/schema.py | 3 +++ .../components/scrape/config_flow.py | 11 ++++++++- homeassistant/components/scrape/const.py | 2 ++ homeassistant/components/scrape/strings.json | 12 ++++++---- tests/components/scrape/conftest.py | 9 +++++++- tests/components/scrape/test_config_flow.py | 9 ++++++++ 9 files changed, 67 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 37c483505b89..637e9da6f9cd 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -41,7 +41,15 @@ from homeassistant.helpers.reload import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_DATA, REST_IDX +from .const import ( + CONF_ENCODING, + COORDINATOR, + DOMAIN, + PLATFORM_IDX, + REST, + REST_DATA, + REST_IDX, +) from .data import RestData from .schema import CONFIG_SCHEMA, RESOURCE_SCHEMA # noqa: F401 @@ -182,7 +190,7 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res headers: dict[str, str] | None = config.get(CONF_HEADERS) params: dict[str, str] | None = config.get(CONF_PARAMS) timeout: int = config[CONF_TIMEOUT] - + encoding: str = config[CONF_ENCODING] if resource_template is not None: resource_template.hass = hass resource = resource_template.async_render(parse_result=False) @@ -201,5 +209,14 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res auth = (username, password) return RestData( - hass, method, resource, auth, headers, params, payload, verify_ssl, timeout + hass, + method, + resource, + encoding, + auth, + headers, + params, + payload, + verify_ssl, + timeout, ) diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index 5fd32d8fba77..bdc0c5af4922 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -5,6 +5,8 @@ DOMAIN = "rest" DEFAULT_METHOD = "GET" DEFAULT_VERIFY_SSL = True DEFAULT_FORCE_UPDATE = False +DEFAULT_ENCODING = "UTF-8" +CONF_ENCODING = "encoding" DEFAULT_BINARY_SENSOR_NAME = "REST Binary Sensor" DEFAULT_SENSOR_NAME = "REST Sensor" diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index c1990b283368..7a5d62694b9f 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -7,7 +7,7 @@ import httpx from homeassistant.core import HomeAssistant from homeassistant.helpers import template -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.httpx_client import create_async_httpx_client DEFAULT_TIMEOUT = 10 @@ -22,6 +22,7 @@ class RestData: hass: HomeAssistant, method: str, resource: str, + encoding: str, auth: httpx.DigestAuth | tuple[str, str] | None, headers: dict[str, str] | None, params: dict[str, str] | None, @@ -33,6 +34,7 @@ class RestData: self._hass = hass self._method = method self._resource = resource + self._encoding = encoding self._auth = auth self._headers = headers self._params = params @@ -51,8 +53,8 @@ class RestData: async def async_update(self, log_errors: bool = True) -> None: """Get the latest data from REST service with provided method.""" if not self._async_client: - self._async_client = get_async_client( - self._hass, verify_ssl=self._verify_ssl + self._async_client = create_async_httpx_client( + self._hass, verify_ssl=self._verify_ssl, default_encoding=self._encoding ) rendered_headers = template.render_complex(self._headers, parse_result=False) diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index cfd8f8a38527..8e0fa9de00e7 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -33,8 +33,10 @@ from homeassistant.helpers.template_entity import ( ) from .const import ( + CONF_ENCODING, CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, + DEFAULT_ENCODING, DEFAULT_FORCE_UPDATE, DEFAULT_METHOD, DEFAULT_VERIFY_SSL, @@ -57,6 +59,7 @@ RESOURCE_SCHEMA = { vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, } SENSOR_SCHEMA = { diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 419dd04f606b..1e3635a010c3 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -60,7 +60,15 @@ from homeassistant.helpers.selector import ( ) from . import COMBINED_SCHEMA -from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN +from .const import ( + CONF_ENCODING, + CONF_INDEX, + CONF_SELECT, + DEFAULT_ENCODING, + DEFAULT_NAME, + DEFAULT_VERIFY_SSL, + DOMAIN, +) RESOURCE_SETUP = { vol.Required(CONF_RESOURCE): TextSelector( @@ -84,6 +92,7 @@ RESOURCE_SETUP = { vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) ), + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } SENSOR_SETUP = { diff --git a/homeassistant/components/scrape/const.py b/homeassistant/components/scrape/const.py index fc433ebb6f0b..cd64199fa23f 100644 --- a/homeassistant/components/scrape/const.py +++ b/homeassistant/components/scrape/const.py @@ -6,11 +6,13 @@ from datetime import timedelta from homeassistant.const import Platform DOMAIN = "scrape" +DEFAULT_ENCODING = "UTF-8" DEFAULT_NAME = "Web scrape" DEFAULT_VERIFY_SSL = True DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) PLATFORMS = [Platform.SENSOR] +CONF_ENCODING = "encoding" CONF_SELECT = "select" CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 061518cb1dbf..052ef22848f8 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -16,14 +16,16 @@ "password": "[%key:common::config_flow::data::password%]", "headers": "Headers", "method": "Method", - "timeout": "Timeout" + "timeout": "Timeout", + "encoding": "Character encoding" }, "data_description": { "resource": "The URL to the website that contains the value", "authentication": "Type of the HTTP authentication. Either basic or digest", "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", "headers": "Headers to use for the web request", - "timeout": "Timeout for connection to website" + "timeout": "Timeout for connection to website", + "encoding": "Character encoding to use. Defaults to UTF-8" } }, "sensor": { @@ -110,14 +112,16 @@ "password": "[%key:component::scrape::config::step::user::data::password%]", "headers": "[%key:component::scrape::config::step::user::data::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", - "timeout": "[%key:component::scrape::config::step::user::data::timeout%]" + "timeout": "[%key:component::scrape::config::step::user::data::timeout%]", + "encoding": "[%key:component::scrape::config::step::user::data::encoding%]" }, "data_description": { "resource": "[%key:component::scrape::config::step::user::data_description::resource%]", "authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]", "headers": "[%key:component::scrape::config::step::user::data_description::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", - "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]" + "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]", + "encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]" } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index fa90786ec2ff..5ad4f39844e4 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -9,7 +9,13 @@ import pytest from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD, DEFAULT_VERIFY_SSL -from homeassistant.components.scrape.const import CONF_INDEX, CONF_SELECT, DOMAIN +from homeassistant.components.scrape.const import ( + CONF_ENCODING, + CONF_INDEX, + CONF_SELECT, + DEFAULT_ENCODING, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_METHOD, @@ -38,6 +44,7 @@ async def get_config_to_integration_load() -> dict[str, Any]: CONF_METHOD: DEFAULT_METHOD, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, "sensor": [ { CONF_NAME: "Current version", diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e12a7c15a0ca..e508937fed84 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -9,8 +9,10 @@ from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN from homeassistant.components.scrape.const import ( + CONF_ENCODING, CONF_INDEX, CONF_SELECT, + DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) from homeassistant.const import ( @@ -75,6 +77,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", @@ -165,6 +168,7 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", @@ -206,6 +210,7 @@ async def test_options_resource_flow( CONF_METHOD: DEFAULT_METHOD, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, CONF_USERNAME: "secret_username", CONF_PASSWORD: "secret_password", }, @@ -218,6 +223,7 @@ async def test_options_resource_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", CONF_USERNAME: "secret_username", CONF_PASSWORD: "secret_password", "sensor": [ @@ -282,6 +288,7 @@ async def test_options_add_remove_sensor_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", @@ -341,6 +348,7 @@ async def test_options_add_remove_sensor_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Template", @@ -407,6 +415,7 @@ async def test_options_edit_sensor_flow( CONF_METHOD: "GET", CONF_VERIFY_SSL: True, CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", "sensor": [ { CONF_NAME: "Current version", From e4bb339a1e80eb5db9139568d035c4d60c0076ba Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 28 Mar 2023 12:43:00 +0200 Subject: [PATCH 0873/1058] Add device info to Nextcloud integration (#90328) * add device_info * use entry_id as identifier + device name * use shorthand attributes * remove model from device info Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- .../components/nextcloud/binary_sensor.py | 2 +- homeassistant/components/nextcloud/entity.py | 20 +++++++++++++------ homeassistant/components/nextcloud/sensor.py | 2 +- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py index 0d960bea8ef0..3cf3cc3ae2a7 100644 --- a/homeassistant/components/nextcloud/binary_sensor.py +++ b/homeassistant/components/nextcloud/binary_sensor.py @@ -25,7 +25,7 @@ async def async_setup_entry( coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - NextcloudBinarySensor(coordinator, name) + NextcloudBinarySensor(coordinator, name, entry) for name in coordinator.data if name in BINARY_SENSORS ] diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index 54976351dd28..ed5882cfe749 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -1,23 +1,31 @@ """Base entity for the Nextcloud integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): """Base Nextcloud entity.""" + _attr_has_entity_name = True _attr_icon = "mdi:cloud" - def __init__(self, coordinator: NextcloudDataUpdateCoordinator, item: str) -> None: + def __init__( + self, coordinator: NextcloudDataUpdateCoordinator, item: str, entry: ConfigEntry + ) -> None: """Initialize the Nextcloud sensor.""" super().__init__(coordinator) self.item = item self._attr_name = item - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return f"{self.coordinator.url}#{self.item}" + self._attr_unique_id = f"{coordinator.url}#{item}" + self._attr_device_info = DeviceInfo( + name="Nextcloud", + identifiers={(DOMAIN, entry.entry_id)}, + sw_version=coordinator.data.get("nextcloud_system_version"), + configuration_url=coordinator.url, + ) diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index eb6043e4bc6d..a5df872e0843 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -65,7 +65,7 @@ async def async_setup_entry( coordinator: NextcloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ - NextcloudSensor(coordinator, name) + NextcloudSensor(coordinator, name, entry) for name in coordinator.data if name in SENSORS ] From 45753521010ad844aa6d3177b0371d937e98530d Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Tue, 28 Mar 2023 12:50:57 +0200 Subject: [PATCH 0874/1058] Bump python-bsblan to 0.5.11 (#90377) --- 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 f53e395f0c53..0e945d13d484 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bsblan", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==0.5.9"] + "requirements": ["python-bsblan==0.5.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index c3dfa944f11b..45d6c88ae8f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2021,7 +2021,7 @@ pythinkingcleaner==0.0.3 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==0.5.9 +python-bsblan==0.5.11 # homeassistant.components.clementine python-clementine-remote==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6edc309c811e..2da989a58a3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1468,7 +1468,7 @@ pytankerkoenig==0.0.6 pytautulli==23.1.1 # homeassistant.components.bsblan -python-bsblan==0.5.9 +python-bsblan==0.5.11 # homeassistant.components.ecobee python-ecobee-api==0.2.14 From b207790177ac337745da172857b1dde54569663c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 01:01:41 -1000 Subject: [PATCH 0875/1058] Fix benign typo in discovery flow helper (#90396) --- homeassistant/helpers/discovery_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index bd5ee4942d0e..586824b4495f 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -11,7 +11,7 @@ from homeassistant.loader import bind_hass from homeassistant.util.async_ import gather_with_concurrency FLOW_INIT_LIMIT = 2 -DISCOVERY_FLOW_DISPATCHER = "discovery_flow_disptacher" +DISCOVERY_FLOW_DISPATCHER = "discovery_flow_dispatcher" @bind_hass From b6a0ac6f0ad9abcda45d7395e2ae8997398037e5 Mon Sep 17 00:00:00 2001 From: Jan Iven Date: Tue, 28 Mar 2023 13:04:08 +0200 Subject: [PATCH 0876/1058] Fix envoy last_seven_days_energy* state class (#84528) Co-authored-by: Franck Nijhof --- homeassistant/components/enphase_envoy/const.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index cd3235f1be5c..4a105e5a067d 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -33,7 +33,6 @@ SENSORS = ( key="seven_days_production", name="Last Seven Days Energy Production", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( @@ -61,7 +60,6 @@ SENSORS = ( key="seven_days_consumption", name="Last Seven Days Energy Consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), SensorEntityDescription( From e6c94d78548625f3148af10877b824924e7cbb45 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 28 Mar 2023 13:05:09 +0200 Subject: [PATCH 0877/1058] Remove mysensors notify (#90402) --- .../components/mysensors/__init__.py | 51 ++------- homeassistant/components/mysensors/const.py | 8 +- homeassistant/components/mysensors/notify.py | 100 ------------------ .../components/mysensors/test_config_flow.py | 15 --- tests/components/mysensors/test_notify.py | 95 ----------------- 5 files changed, 8 insertions(+), 261 deletions(-) delete mode 100644 homeassistant/components/mysensors/notify.py delete mode 100644 tests/components/mysensors/test_notify.py diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index d8c3debe7ed8..129b14306251 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from mysensors import BaseAsyncGateway @@ -12,24 +11,19 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DEVICES, DOMAIN, - MYSENSORS_DISCOVERY, MYSENSORS_GATEWAYS, MYSENSORS_ON_UNLOAD, - PLATFORMS_WITH_ENTRY_SUPPORT, + PLATFORMS, DevId, DiscoveryInfo, SensorType, ) from .device import MySensorsDevice, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway -from .helpers import on_unload _LOGGER = logging.getLogger(__name__) @@ -39,14 +33,6 @@ DATA_HASS_CONFIG = "hass_config" CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the MySensors component.""" - # This is needed to set up the notify platform via discovery. - hass.data[DOMAIN] = {DATA_HASS_CONFIG: config} - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an instance of the MySensors integration. @@ -58,33 +44,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Gateway setup failed for %s", entry.data) return False - if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]: - hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {} - hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway + mysensors_data = hass.data.setdefault(DOMAIN, {}) + if MYSENSORS_GATEWAYS not in mysensors_data: + mysensors_data[MYSENSORS_GATEWAYS] = {} + mysensors_data[MYSENSORS_GATEWAYS][entry.entry_id] = gateway - # Connect notify discovery as that integration doesn't support entry forwarding. - - load_discovery_platform = partial( - async_load_platform, - hass, - Platform.NOTIFY, - DOMAIN, - hass_config=hass.data[DOMAIN][DATA_HASS_CONFIG], - ) - - on_unload( - hass, - entry.entry_id, - async_dispatcher_connect( - hass, - MYSENSORS_DISCOVERY.format(entry.entry_id, Platform.NOTIFY), - load_discovery_platform, - ), - ) - - await hass.config_entries.async_forward_entry_setups( - entry, PLATFORMS_WITH_ENTRY_SUPPORT - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await finish_setup(hass, entry, gateway) return True @@ -95,9 +60,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms( - entry, PLATFORMS_WITH_ENTRY_SUPPORT - ) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not unload_ok: return False diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 5368f65b83e1..bcdc6f80ab21 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -40,7 +40,6 @@ class DiscoveryInfo(TypedDict): """Represent the discovery info type for mysensors platforms.""" devices: list[DevId] - name: str # CONF_NAME is used in the notify base integration. gateway_id: GatewayId @@ -92,8 +91,6 @@ LIGHT_TYPES: dict[SensorType, set[ValueType]] = { "S_RGBW_LIGHT": {"V_RGBW"}, } -NOTIFY_TYPES: dict[SensorType, set[ValueType]] = {"S_INFO": {"V_TEXT"}} - REMOTE_TYPES: dict[SensorType, set[ValueType]] = {"S_IR": {"V_IR_SEND"}} SENSOR_TYPES: dict[SensorType, set[ValueType]] = { @@ -148,7 +145,6 @@ PLATFORM_TYPES: dict[Platform, dict[SensorType, set[ValueType]]] = { Platform.COVER: COVER_TYPES, Platform.DEVICE_TRACKER: DEVICE_TRACKER_TYPES, Platform.LIGHT: LIGHT_TYPES, - Platform.NOTIFY: NOTIFY_TYPES, Platform.REMOTE: REMOTE_TYPES, Platform.SENSOR: SENSOR_TYPES, Platform.SWITCH: SWITCH_TYPES, @@ -167,6 +163,4 @@ for platform, platform_types in PLATFORM_TYPES.items(): for s_type_name in platform_types: TYPE_TO_PLATFORMS[s_type_name].append(platform) -PLATFORMS_WITH_ENTRY_SUPPORT = set(PLATFORM_TYPES.keys()) - { - Platform.NOTIFY, -} +PLATFORMS = tuple(PLATFORM_TYPES) diff --git a/homeassistant/components/mysensors/notify.py b/homeassistant/components/mysensors/notify.py deleted file mode 100644 index 97d4175a6f20..000000000000 --- a/homeassistant/components/mysensors/notify.py +++ /dev/null @@ -1,100 +0,0 @@ -"""MySensors notification service.""" -from __future__ import annotations - -from typing import Any, cast - -from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify - -from .. import mysensors -from .const import DOMAIN, DevId, DiscoveryInfo - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> BaseNotificationService | None: - """Get the MySensors notification service.""" - if not discovery_info: - return None - - new_devices = mysensors.setup_mysensors_platform( - hass, - Platform.NOTIFY, - cast(DiscoveryInfo, discovery_info), - MySensorsNotificationDevice, - ) - if not new_devices: - return None - return MySensorsNotificationService(hass) - - -class MySensorsNotificationDevice(mysensors.device.MySensorsDevice): - """Represent a MySensors Notification device.""" - - @callback - def _async_update_callback(self) -> None: - """Update the device.""" - self._async_update() - - def send_msg(self, msg: str) -> None: - """Send a message.""" - for sub_msg in [msg[i : i + 25] for i in range(0, len(msg), 25)]: - # Max mysensors payload is 25 bytes. - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, sub_msg - ) - - def __repr__(self) -> str: - """Return the representation.""" - return f"" - - -class MySensorsNotificationService(BaseNotificationService): - """Implement a MySensors notification service.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the service.""" - self.devices: dict[ - DevId, MySensorsNotificationDevice - ] = mysensors.get_mysensors_devices( - hass, Platform.NOTIFY - ) # type: ignore[assignment] - self.hass = hass - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a user.""" - target_devices = kwargs.get(ATTR_TARGET) - devices = [ - device - for device in self.devices.values() - if target_devices is None or device.name in target_devices - ] - - placeholders = { - "alternate_service": "text.set_value", - "deprecated_service": f"notify.{self._service_name}", - "alternate_target": str( - [f"text.{slugify(device.name)}" for device in devices] - ), - } - - async_create_issue( - self.hass, - DOMAIN, - "deprecated_notify_service", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service", - translation_placeholders=placeholders, - ) - - for device in devices: - device.send_msg(message) diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py index 98a6ae3b2341..dc24a48edd41 100644 --- a/tests/components/mysensors/test_config_flow.py +++ b/tests/components/mysensors/test_config_flow.py @@ -61,8 +61,6 @@ async def test_config_mqtt(hass: HomeAssistant, mqtt: None) -> None: flow_id = step["flow_id"] with patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -89,7 +87,6 @@ async def test_config_mqtt(hass: HomeAssistant, mqtt: None) -> None: CONF_VERSION: "2.4", CONF_GATEWAY_TYPE: "MQTT", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -121,8 +118,6 @@ async def test_config_serial(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.mysensors.config_flow.try_connect", return_value=True ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -146,7 +141,6 @@ async def test_config_serial(hass: HomeAssistant) -> None: CONF_VERSION: "2.4", CONF_GATEWAY_TYPE: "Serial", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -158,8 +152,6 @@ async def test_config_tcp(hass: HomeAssistant) -> None: with patch( "homeassistant.components.mysensors.config_flow.try_connect", return_value=True ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -183,7 +175,6 @@ async def test_config_tcp(hass: HomeAssistant) -> None: CONF_VERSION: "2.4", CONF_GATEWAY_TYPE: "TCP", } - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -195,8 +186,6 @@ async def test_fail_to_connect(hass: HomeAssistant) -> None: with patch( "homeassistant.components.mysensors.config_flow.try_connect", return_value=False ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -215,7 +204,6 @@ async def test_fail_to_connect(hass: HomeAssistant) -> None: errors = result["errors"] assert errors assert errors.get("base") == "cannot_connect" - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 @@ -358,8 +346,6 @@ async def test_config_invalid( "homeassistant.components.mysensors.gateway.socket.getaddrinfo", side_effect=OSError, ), patch( - "homeassistant.components.mysensors.async_setup", return_value=True - ) as mock_setup, patch( "homeassistant.components.mysensors.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -375,7 +361,6 @@ async def test_config_invalid( assert errors assert err_field in errors assert errors[err_field] == err_string - assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/mysensors/test_notify.py b/tests/components/mysensors/test_notify.py deleted file mode 100644 index e96b463cc783..000000000000 --- a/tests/components/mysensors/test_notify.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Provide tests for mysensors notify platform.""" -from __future__ import annotations - -from collections.abc import Callable -from unittest.mock import MagicMock, call - -from mysensors.sensor import Sensor - -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def test_text_type( - hass: HomeAssistant, - text_node: Sensor, - transport_write: MagicMock, - integration: MockConfigEntry, -) -> None: - """Test a text type child.""" - # Test without target. - await hass.services.async_call( - NOTIFY_DOMAIN, "mysensors", {"message": "Hello World"}, blocking=True - ) - - assert transport_write.call_count == 1 - assert transport_write.call_args == call("1;1;1;0;47;Hello World\n") - - # Test with target. - await hass.services.async_call( - NOTIFY_DOMAIN, - "mysensors", - {"message": "Hello", "target": "Text Node 1 1"}, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args == call("1;1;1;0;47;Hello\n") - - transport_write.reset_mock() - - # Test a message longer than 25 characters. - await hass.services.async_call( - NOTIFY_DOMAIN, - "mysensors", - { - "message": "This is a long message that will be split", - "target": "Text Node 1 1", - }, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list == [ - call("1;1;1;0;47;This is a long message th\n"), - call("1;1;1;0;47;at will be split\n"), - ] - - -async def test_text_type_discovery( - hass: HomeAssistant, - text_node: Sensor, - transport_write: MagicMock, - receive_message: Callable[[str], None], -) -> None: - """Test text type discovery.""" - receive_message("1;2;0;0;36;\n") - receive_message("1;2;1;0;47;test\n") - receive_message("1;2;1;0;47;test2\n") # Test that more than one set message works. - await hass.async_block_till_done() - - # Test targeting the discovered child. - await hass.services.async_call( - NOTIFY_DOMAIN, - "mysensors", - {"message": "Hello", "target": "Text Node 1 2"}, - blocking=True, - ) - - assert transport_write.call_count == 1 - assert transport_write.call_args == call("1;2;1;0;47;Hello\n") - - transport_write.reset_mock() - - # Test targeting all notify children. - await hass.services.async_call( - NOTIFY_DOMAIN, "mysensors", {"message": "Hello World"}, blocking=True - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list == [ - call("1;1;1;0;47;Hello World\n"), - call("1;2;1;0;47;Hello World\n"), - ] From de2ca31a71399a6015fa02445913e053a47235eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 01:08:43 -1000 Subject: [PATCH 0878/1058] Remove lru_cache on websocket _state_diff (#90392) --- homeassistant/components/websocket_api/messages.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 0765c6a5b7c6..ec1ab267a37b 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -132,7 +132,6 @@ def _state_diff_event(event: Event) -> dict: return _state_diff(event_old_state, event_new_state) -@lru_cache(maxsize=128) def _state_diff( old_state: State, new_state: State ) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]: From f72bf73b0338a3da4ba9dfe9e9fa185a676fc8a3 Mon Sep 17 00:00:00 2001 From: PatrickGlesner <34370149+PatrickGlesner@users.noreply.github.com> Date: Tue, 28 Mar 2023 13:19:52 +0200 Subject: [PATCH 0879/1058] Fix NMBS IndexError (#90365) --- homeassistant/components/nmbs/sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index b9a216875f4b..8fb227140a1f 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -162,7 +162,13 @@ class NMBSLiveBoard(SensorEntity): """Set the state equal to the next departure.""" liveboard = self._api_client.get_liveboard(self._station) - if liveboard is None or not liveboard.get("departures"): + if ( + liveboard is None + or liveboard.get("departures") is None + or liveboard.get("departures").get("number") is None + or liveboard.get("departures").get("number") == "0" + or liveboard.get("departures").get("departure") is None + ): return next_departure = liveboard["departures"]["departure"][0] From 08444eeb7608b2233439ce5a90364d113a664e1b Mon Sep 17 00:00:00 2001 From: Geoff Date: Tue, 28 Mar 2023 04:20:20 -0700 Subject: [PATCH 0880/1058] Update transmission up/down speed values (#88528) Co-authored-by: Erik Montnemery Co-authored-by: Franck Nijhof --- homeassistant/components/transmission/sensor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 914777313177..2c7bf24cdfd7 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -109,18 +109,19 @@ class TransmissionSpeedSensor(TransmissionSensor): """Representation of a Transmission speed sensor.""" _attr_device_class = SensorDeviceClass.DATA_RATE - _attr_native_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND + _attr_native_unit_of_measurement = UnitOfDataRate.BYTES_PER_SECOND + _attr_suggested_display_precision = 2 + _attr_suggested_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND def update(self) -> None: """Get the latest data from Transmission and updates the state.""" if data := self._tm_client.api.data: - mb_spd = ( + b_spd = ( float(data.downloadSpeed) if self._sub_type == "download" else float(data.uploadSpeed) ) - mb_spd = mb_spd / 1024 / 1024 - self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) + self._state = b_spd class TransmissionStatusSensor(TransmissionSensor): From 6e23e00b5a6152bec7ddd6d1fbd851ee552c84aa Mon Sep 17 00:00:00 2001 From: MarkGodwin Date: Tue, 28 Mar 2023 12:25:10 +0100 Subject: [PATCH 0881/1058] TP-Link Omada update entities code review feedback (#89668) --- .../components/tplink_omada/__init__.py | 2 +- .../components/tplink_omada/controller.py | 44 +++--- .../components/tplink_omada/coordinator.py | 9 +- .../components/tplink_omada/manifest.json | 2 +- .../components/tplink_omada/switch.py | 5 +- .../components/tplink_omada/update.py | 132 +++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 102 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 709ad5201259..824ea8df4239 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -44,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unexpected error connecting to Omada controller: {ex}" ) from ex - site_client = await client.get_site_client(OmadaSite(None, entry.data[CONF_SITE])) + site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE])) controller = OmadaSiteController(hass, site_client) hass.data[DOMAIN][entry.entry_id] = controller diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index b42cb37ff76f..508a8b914da9 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,7 +1,5 @@ """Controller for sharing Omada API coordinators between platforms.""" -from functools import partial - from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails from tplink_omada_client.omadasiteclient import OmadaSiteClient @@ -9,13 +7,28 @@ from homeassistant.core import HomeAssistant from .coordinator import OmadaCoordinator +POLL_SWITCH_PORT = 300 -async def _poll_switch_state( - client: OmadaSiteClient, network_switch: OmadaSwitch -) -> dict[str, OmadaSwitchPortDetails]: - """Poll a switch's current state.""" - ports = await client.get_switch_ports(network_switch) - return {p.port_id: p for p in ports} + +class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): + """Coordinator for getting details about ports on a switch.""" + + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + network_switch: OmadaSwitch, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, omada_client, f"{network_switch.name} Ports", POLL_SWITCH_PORT + ) + self._network_switch = network_switch + + async def poll_update(self) -> dict[str, OmadaSwitchPortDetails]: + """Poll a switch's current state.""" + ports = await self.omada_client.get_switch_ports(self._network_switch) + return {p.port_id: p for p in ports} class OmadaSiteController: @@ -26,9 +39,7 @@ class OmadaSiteController: self._hass = hass self._omada_client = omada_client - self._switch_port_coordinators: dict[ - str, OmadaCoordinator[OmadaSwitchPortDetails] - ] = {} + self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {} @property def omada_client(self) -> OmadaSiteClient: @@ -37,16 +48,11 @@ class OmadaSiteController: def get_switch_port_coordinator( self, switch: OmadaSwitch - ) -> OmadaCoordinator[OmadaSwitchPortDetails]: + ) -> OmadaSwitchPortCoordinator: """Get coordinator for network port information of a given switch.""" if switch.mac not in self._switch_port_coordinators: - self._switch_port_coordinators[switch.mac] = OmadaCoordinator[ - OmadaSwitchPortDetails - ]( - self._hass, - self._omada_client, - f"{switch.name} Ports", - partial(_poll_switch_state, network_switch=switch), + self._switch_port_coordinators[switch.mac] = OmadaSwitchPortCoordinator( + self._hass, self._omada_client, switch ) return self._switch_port_coordinators[switch.mac] diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index d73461dc786b..3ff73501bdc0 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -1,5 +1,4 @@ """Generic Omada API coordinator.""" -from collections.abc import Awaitable, Callable from datetime import timedelta import logging from typing import Generic, TypeVar @@ -24,7 +23,6 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): hass: HomeAssistant, omada_client: OmadaSiteClient, name: str, - update_func: Callable[[OmadaSiteClient], Awaitable[dict[str, T]]], poll_delay: int = 300, ) -> None: """Initialize my coordinator.""" @@ -35,12 +33,15 @@ class OmadaCoordinator(DataUpdateCoordinator[dict[str, T]], Generic[T]): update_interval=timedelta(seconds=poll_delay), ) self.omada_client = omada_client - self._update_func = update_func async def _async_update_data(self) -> dict[str, T]: """Fetch data from API endpoint.""" try: async with async_timeout.timeout(10): - return await self._update_func(self.omada_client) + return await self.poll_update() except OmadaClientException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def poll_update(self) -> dict[str, T]: + """Poll the current data from the controller.""" + raise NotImplementedError("Update method not implemented") diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index a0fb58b3f6c6..9d7234077645 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.1.3"] + "requirements": ["tplink-omada-client==1.1.4"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index e85b1c181fcd..830f75b6a936 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -14,8 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .controller import OmadaSiteController -from .coordinator import OmadaCoordinator +from .controller import OmadaSiteController, OmadaSwitchPortCoordinator from .entity import OmadaDeviceEntity POE_SWITCH_ICON = "mdi:ethernet" @@ -68,7 +67,7 @@ class OmadaNetworkSwitchPortPoEControl( def __init__( self, - coordinator: OmadaCoordinator[OmadaSwitchPortDetails], + coordinator: OmadaSwitchPortCoordinator, device: OmadaSwitch, port_id: str, ) -> None: diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 5581f61d824a..685ad9c57614 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -1,24 +1,26 @@ -"""Support for TPLink Omada device toggle options.""" +"""Support for TPLink Omada device firmware updates.""" from __future__ import annotations -import logging +from datetime import timedelta from typing import Any, NamedTuple from tplink_omada_client.devices import OmadaFirmwareUpdate, OmadaListDevice +from tplink_omada_client.exceptions import OmadaClientException, RequestFailed from tplink_omada_client.omadasiteclient import OmadaSiteClient from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from .const import DOMAIN from .controller import OmadaSiteController from .coordinator import OmadaCoordinator from .entity import OmadaDeviceEntity -_LOGGER = logging.getLogger(__name__) +POLL_DELAY_IDLE = 6 * 60 * 60 +POLL_DELAY_UPGRADE = 60 class FirmwareUpdateStatus(NamedTuple): @@ -28,24 +30,39 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -async def _get_firmware_updates(client: OmadaSiteClient) -> list[FirmwareUpdateStatus]: - devices = await client.get_devices() - return [ - FirmwareUpdateStatus( - device=d, - firmware=None - if not d.need_upgrade - else await client.get_firmware_details(d), +class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): + """Coordinator for getting details about ports on a switch.""" + + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + """Initialize my coordinator.""" + super().__init__(hass, omada_client, "Firmware Updates", POLL_DELAY_IDLE) + + async def _get_firmware_updates(self) -> list[FirmwareUpdateStatus]: + devices = await self.omada_client.get_devices() + + updates = [ + FirmwareUpdateStatus( + device=d, + firmware=None + if not d.need_upgrade + else await self.omada_client.get_firmware_details(d), + ) + for d in devices + ] + + # During a firmware upgrade, poll more frequently + self.update_interval = timedelta( + seconds=( + POLL_DELAY_UPGRADE + if any(u.device.fw_download for u in updates) + else POLL_DELAY_IDLE + ) ) - for d in devices - ] + return updates - -async def _poll_firmware_updates( - client: OmadaSiteClient, -) -> dict[str, FirmwareUpdateStatus]: - """Poll the state of Omada Devices firmware update availability.""" - return {d.device.mac: d for d in await _get_firmware_updates(client)} + async def poll_update(self) -> dict[str, FirmwareUpdateStatus]: + """Poll the state of Omada Devices firmware update availability.""" + return {d.device.mac: d for d in await self._get_firmware_updates()} async def async_setup_entry( @@ -59,19 +76,9 @@ async def async_setup_entry( devices = await omada_client.get_devices() - coordinator = OmadaCoordinator[FirmwareUpdateStatus]( - hass, - omada_client, - "Firmware Updates", - _poll_firmware_updates, - poll_delay=6 * 60 * 60, - ) + coordinator = OmadaFirmwareUpdateCoodinator(hass, omada_client) - entities: list = [] - for device in devices: - entities.append(OmadaDeviceUpdate(coordinator, device)) - - async_add_entities(entities) + async_add_entities(OmadaDeviceUpdate(coordinator, device) for device in devices) await coordinator.async_request_refresh() @@ -86,64 +93,57 @@ class OmadaDeviceUpdate( | UpdateEntityFeature.PROGRESS | UpdateEntityFeature.RELEASE_NOTES ) - _firmware_update: OmadaFirmwareUpdate = None + _attr_has_entity_name = True + _attr_name = "Firmware update" def __init__( self, - coordinator: OmadaCoordinator[FirmwareUpdateStatus], + coordinator: OmadaFirmwareUpdateCoodinator, device: OmadaListDevice, ) -> None: """Initialize the update entity.""" super().__init__(coordinator, device) self._mac = device.mac - self._device = device self._omada_client = coordinator.omada_client self._attr_unique_id = f"{device.mac}_firmware" - self._attr_has_entity_name = True - self._attr_name = "Firmware Update" - self._refresh_state() - - def _refresh_state(self) -> None: - if self._firmware_update and self._device.need_upgrade: - self._attr_installed_version = self._firmware_update.current_version - self._attr_latest_version = self._firmware_update.latest_version - else: - self._attr_installed_version = self._device.firmware_version - self._attr_latest_version = self._device.firmware_version - self._attr_in_progress = self._device.fw_download - - if self._attr_in_progress: - # While firmware update is in progress, poll more frequently - async_call_later(self.hass, 60, self._request_refresh) - - async def _request_refresh(self, _now: Any) -> None: - await self.coordinator.async_request_refresh() def release_notes(self) -> str | None: """Get the release notes for the latest update.""" - if self._firmware_update: - return str(self._firmware_update.release_notes) - return "" + status = self.coordinator.data[self._mac] + if status.firmware: + return status.firmware.release_notes + return None async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install a firmware update.""" - if self._firmware_update and ( - version is None or self._firmware_update.latest_version == version - ): - await self._omada_client.start_firmware_upgrade(self._device) + try: + await self._omada_client.start_firmware_upgrade( + self.coordinator.data[self._mac].device + ) + except RequestFailed as ex: + raise HomeAssistantError("Firmware update request rejected") from ex + except OmadaClientException as ex: + raise HomeAssistantError( + "Unable to send Firmware update request. Check the controller is online." + ) from ex + finally: await self.coordinator.async_request_refresh() - else: - _LOGGER.error("Firmware upgrade is not available for %s", self._device.name) @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" status = self.coordinator.data[self._mac] - self._device = status.device - self._firmware_update = status.firmware - self._refresh_state() + + if status.firmware and status.device.need_upgrade: + self._attr_installed_version = status.firmware.current_version + self._attr_latest_version = status.firmware.latest_version + else: + self._attr_installed_version = status.device.firmware_version + self._attr_latest_version = status.device.firmware_version + self._attr_in_progress = status.device.fw_download + self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 45d6c88ae8f8..1560012227f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2521,7 +2521,7 @@ total_connect_client==2023.2 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.3 +tplink-omada-client==1.1.4 # homeassistant.components.transmission transmission-rpc==3.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2da989a58a3c..e1235f47340c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1788,7 +1788,7 @@ toonapi==0.2.1 total_connect_client==2023.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.1.3 +tplink-omada-client==1.1.4 # homeassistant.components.transmission transmission-rpc==3.4.0 From 29645d5820a1240155493d4651ae5895e1928e07 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 28 Mar 2023 13:39:32 +0200 Subject: [PATCH 0882/1058] Remove mysensors ir switch (#90403) --- .../components/mysensors/__init__.py | 12 +- homeassistant/components/mysensors/const.py | 1 - homeassistant/components/mysensors/device.py | 4 +- homeassistant/components/mysensors/light.py | 4 +- .../components/mysensors/services.yaml | 18 -- .../components/mysensors/strings.json | 24 --- homeassistant/components/mysensors/switch.py | 158 +----------------- tests/components/mysensors/test_switch.py | 79 --------- 8 files changed, 18 insertions(+), 282 deletions(-) delete mode 100644 homeassistant/components/mysensors/services.yaml diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 129b14306251..5b8154e17aa5 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,7 +1,7 @@ """Connect to a MySensors gateway via pymysensors API.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping import logging from mysensors import BaseAsyncGateway @@ -22,7 +22,7 @@ from .const import ( DiscoveryInfo, SensorType, ) -from .device import MySensorsDevice, get_mysensors_devices +from .device import MySensorsEntity, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway _LOGGER = logging.getLogger(__name__) @@ -99,12 +99,12 @@ def setup_mysensors_platform( hass: HomeAssistant, domain: Platform, # hass platform name discovery_info: DiscoveryInfo, - device_class: type[MySensorsDevice] | dict[SensorType, type[MySensorsDevice]], + device_class: type[MySensorsEntity] | Mapping[SensorType, type[MySensorsEntity]], device_args: ( None | tuple ) = None, # extra arguments that will be given to the entity constructor async_add_entities: Callable | None = None, -) -> list[MySensorsDevice] | None: +) -> list[MySensorsEntity] | None: """Set up a MySensors platform. Sets up a bunch of instances of a single platform that is supported by this @@ -118,10 +118,10 @@ def setup_mysensors_platform( """ if device_args is None: device_args = () - new_devices: list[MySensorsDevice] = [] + new_devices: list[MySensorsEntity] = [] new_dev_ids: list[DevId] = discovery_info[ATTR_DEVICES] for dev_id in new_dev_ids: - devices: dict[DevId, MySensorsDevice] = get_mysensors_devices(hass, domain) + devices: dict[DevId, MySensorsEntity] = get_mysensors_devices(hass, domain) if dev_id in devices: _LOGGER.debug( "Skipping setup of %s for platform %s as it already exists", diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index bcdc6f80ab21..7f9326091fe2 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -132,7 +132,6 @@ SWITCH_TYPES: dict[SensorType, set[ValueType]] = { "S_SOUND": {"V_ARMED"}, "S_VIBRATION": {"V_ARMED"}, "S_MOISTURE": {"V_ARMED"}, - "S_IR": {"V_IR_SEND"}, "S_LOCK": {"V_LOCK_STATUS"}, "S_WATER_QUALITY": {"V_STATUS"}, } diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index de4cbff9b9d3..d7405dba187e 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -202,11 +202,11 @@ class MySensorsDevice(ABC): def get_mysensors_devices( hass: HomeAssistant, domain: Platform -) -> dict[DevId, MySensorsDevice]: +) -> dict[DevId, MySensorsEntity]: """Return MySensors devices for a hass platform name.""" if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - devices: dict[DevId, MySensorsDevice] = hass.data[DOMAIN][ + devices: dict[DevId, MySensorsEntity] = hass.data[DOMAIN][ MYSENSORS_PLATFORM_DEVICES.format(domain) ] return devices diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index e83002ed870a..68f8bb566f1d 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -19,7 +19,7 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsDevice +from .device import MySensorsEntity from .helpers import on_unload @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsDevice]] = { + device_class_map: dict[SensorType, type[MySensorsEntity]] = { "S_DIMMER": MySensorsLightDimmer, "S_RGB_LIGHT": MySensorsLightRGB, "S_RGBW_LIGHT": MySensorsLightRGBW, diff --git a/homeassistant/components/mysensors/services.yaml b/homeassistant/components/mysensors/services.yaml deleted file mode 100644 index 7293a676a76f..000000000000 --- a/homeassistant/components/mysensors/services.yaml +++ /dev/null @@ -1,18 +0,0 @@ -send_ir_code: - name: Send IR code - description: Set an IR code as a state attribute for a MySensors IR device switch and turn the switch on. - fields: - entity_id: - name: Entity - description: Name of entity that should have the IR code set and be turned on. Platform dependent. - selector: - entity: - integration: mysensors - domain: switch - V_IR_SEND: - name: IR send - description: IR code to send. - required: true - example: "0xC284" - selector: - text: diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index c192db7549f3..dc5dc76c7ae3 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -83,29 +83,5 @@ "port_out_of_range": "Port number must be at least 1 and at most 65535", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "deprecated_entity": { - "title": "The {deprecated_entity} entity will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_entity} entity will be removed", - "description": "Update any automations or scripts that use this entity in service calls using the `{deprecated_service}` service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - } - } - } - }, - "deprecated_service": { - "title": "The {deprecated_service} service will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_service} service will be removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - } - } - } - } } } diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index e5b0968785fb..6067a98af084 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -3,34 +3,18 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform -from homeassistant.core import HomeAssistant, ServiceCall, callback, split_entity_id -import homeassistant.helpers.config_validation as cv +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant 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 .. import mysensors -from .const import ( - DOMAIN as MYSENSORS_DOMAIN, - MYSENSORS_DISCOVERY, - SERVICE_SEND_IR_CODE, - DiscoveryInfo, - SensorType, -) -from .device import MySensorsDevice +from . import setup_mysensors_platform +from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType +from .device import MySensorsEntity from .helpers import on_unload -ATTR_IR_CODE = "V_IR_SEND" - -SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema( - {vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_IR_CODE): cv.string} -) - async def async_setup_entry( hass: HomeAssistant, @@ -38,13 +22,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsDevice]] = { + device_class_map: dict[SensorType, type[MySensorsSwitch]] = { "S_DOOR": MySensorsSwitch, "S_MOTION": MySensorsSwitch, "S_SMOKE": MySensorsSwitch, "S_LIGHT": MySensorsSwitch, "S_LOCK": MySensorsSwitch, - "S_IR": MySensorsIRSwitch, "S_BINARY": MySensorsSwitch, "S_SPRINKLER": MySensorsSwitch, "S_WATER_LEAK": MySensorsSwitch, @@ -56,7 +39,7 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors switch.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.SWITCH, discovery_info, @@ -64,37 +47,6 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) - async def async_send_ir_code_service(service: ServiceCall) -> None: - """Set IR code as device state attribute.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) - ir_code = service.data.get(ATTR_IR_CODE) - devices = mysensors.get_mysensors_devices(hass, Platform.SWITCH) - - if entity_ids: - _devices = [ - device - for device in devices.values() - if isinstance(device, MySensorsIRSwitch) - and device.entity_id in entity_ids - ] - else: - _devices = [ - device - for device in devices.values() - if isinstance(device, MySensorsIRSwitch) - ] - - kwargs = {ATTR_IR_CODE: ir_code} - for device in _devices: - await device.async_turn_on(**kwargs) - - hass.services.async_register( - MYSENSORS_DOMAIN, - SERVICE_SEND_IR_CODE, - async_send_ir_code_service, - schema=SEND_IR_CODE_SERVICE_SCHEMA, - ) - on_unload( hass, config_entry.entry_id, @@ -106,7 +58,7 @@ async def async_setup_entry( ) -class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): +class MySensorsSwitch(MySensorsEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" @property @@ -133,97 +85,3 @@ class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): # Optimistically assume that switch has changed state self._values[self.value_type] = STATE_OFF self.async_write_ha_state() - - -class MySensorsIRSwitch(MySensorsSwitch): - """IR switch child class to MySensorsSwitch.""" - - def __init__(self, *args: Any) -> None: - """Set up instance attributes.""" - super().__init__(*args) - self._ir_code: str | None = None - - @property - def is_on(self) -> bool: - """Return True if switch is on.""" - set_req = self.gateway.const.SetReq - return self._values.get(set_req.V_LIGHT) == STATE_ON - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the IR switch on.""" - set_req = self.gateway.const.SetReq - placeholders = { - "deprecated_entity": self.entity_id, - "alternate_target": f"remote.{split_entity_id(self.entity_id)[1]}", - } - - if ATTR_IR_CODE in kwargs: - self._ir_code = kwargs[ATTR_IR_CODE] - placeholders[ - "deprecated_service" - ] = f"{MYSENSORS_DOMAIN}.{SERVICE_SEND_IR_CODE}" - placeholders["alternate_service"] = "remote.send_command" - else: - placeholders["deprecated_service"] = "switch.turn_on" - placeholders["alternate_service"] = "remote.turn_on" - - async_create_issue( - self.hass, - MYSENSORS_DOMAIN, - ( - "deprecated_ir_switch_entity_" - f"{self.entity_id}_{placeholders['deprecated_service']}" - ), - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders=placeholders, - ) - self.gateway.set_child_value( - self.node_id, self.child_id, self.value_type, self._ir_code - ) - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1 - ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[self.value_type] = self._ir_code - self._values[set_req.V_LIGHT] = STATE_ON - self.async_write_ha_state() - # Turn off switch after switch was turned on - await self.async_turn_off() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the IR switch off.""" - async_create_issue( - self.hass, - MYSENSORS_DOMAIN, - f"deprecated_ir_switch_entity_{self.entity_id}_switch.turn_off", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders={ - "deprecated_entity": self.entity_id, - "deprecated_service": "switch.turn_off", - "alternate_service": "remote.turn_off", - "alternate_target": f"remote.{split_entity_id(self.entity_id)[1]}", - }, - ) - set_req = self.gateway.const.SetReq - self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_LIGHT, 0, ack=1 - ) - if self.assumed_state: - # Optimistically assume that switch has changed state - self._values[set_req.V_LIGHT] = STATE_OFF - self.async_write_ha_state() - - @callback - def _async_update(self) -> None: - """Update the controller with the latest value from a sensor.""" - super()._async_update() - self._ir_code = self._values.get(self.value_type) diff --git a/tests/components/mysensors/test_switch.py b/tests/components/mysensors/test_switch.py index b77d540d5434..59cea514d778 100644 --- a/tests/components/mysensors/test_switch.py +++ b/tests/components/mysensors/test_switch.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock, call from mysensors.sensor import Sensor -from homeassistant.components.mysensors.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant @@ -62,81 +61,3 @@ async def test_relay_node( assert state assert state.state == "off" - - -async def test_ir_transceiver( - hass: HomeAssistant, - ir_transceiver: Sensor, - receive_message: Callable[[str], None], - transport_write: MagicMock, -) -> None: - """Test an ir transceiver.""" - entity_id = "switch.ir_transceiver_1_1" - - state = hass.states.get(entity_id) - - assert state - assert state.state == "off" - - await hass.services.async_call( - SWITCH_DOMAIN, - "turn_on", - {"entity_id": entity_id}, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list[0] == call("1;1;1;0;32;test_code\n") - assert transport_write.call_args_list[1] == call("1;1;1;1;2;1\n") - - receive_message("1;1;1;0;2;1\n") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - - assert state - assert state.state == "on" - assert state.attributes["V_IR_SEND"] == "test_code" - - transport_write.reset_mock() - - await hass.services.async_call( - SWITCH_DOMAIN, - "turn_off", - {"entity_id": entity_id}, - blocking=True, - ) - - assert transport_write.call_count == 1 - assert transport_write.call_args == call("1;1;1;1;2;0\n") - - receive_message("1;1;1;0;2;0\n") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - - assert state - assert state.state == "off" - - transport_write.reset_mock() - - await hass.services.async_call( - DOMAIN, - "send_ir_code", - {"entity_id": entity_id, "V_IR_SEND": "new_code"}, - blocking=True, - ) - - assert transport_write.call_count == 2 - assert transport_write.call_args_list[0] == call("1;1;1;0;32;new_code\n") - assert transport_write.call_args_list[1] == call("1;1;1;1;2;1\n") - - receive_message("1;1;1;0;32;new_code\n") - receive_message("1;1;1;0;2;1\n") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - - assert state - assert state.state == "on" - assert state.attributes["V_IR_SEND"] == "new_code" From 7b18df321b094d9f450a66553367c3648ddce245 Mon Sep 17 00:00:00 2001 From: Ryan Fleming Date: Tue, 28 Mar 2023 08:00:35 -0400 Subject: [PATCH 0883/1058] Have octoprint camera respect verify_ssl configuration (#90384) --- homeassistant/components/octoprint/camera.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 653c15f18438..9c3049ff87d6 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -5,6 +5,7 @@ from pyoctoprintapi import OctoprintClient, WebcamSettings from homeassistant.components.mjpeg.camera import MjpegCamera from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -28,6 +29,7 @@ async def async_setup_entry( assert device_id is not None camera_info = await client.get_webcam_info() + verify_ssl = config_entry.data[CONF_VERIFY_SSL] if not camera_info or not camera_info.enabled: return @@ -38,6 +40,7 @@ async def async_setup_entry( camera_info, coordinator.device_info, device_id, + verify_ssl, ) ] ) @@ -47,7 +50,11 @@ class OctoprintCamera(MjpegCamera): """Representation of an OctoPrint Camera Stream.""" def __init__( - self, camera_settings: WebcamSettings, device_info: DeviceInfo, device_id: str + self, + camera_settings: WebcamSettings, + device_info: DeviceInfo, + device_id: str, + verify_ssl: bool, ) -> None: """Initialize as a subclass of MjpegCamera.""" super().__init__( @@ -56,4 +63,5 @@ class OctoprintCamera(MjpegCamera): name="OctoPrint Camera", still_image_url=camera_settings.external_snapshot_url, unique_id=device_id, + verify_ssl=verify_ssl, ) From 0eb409cff1a078b36abbdf47005de90a22560167 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 28 Mar 2023 14:01:31 +0200 Subject: [PATCH 0884/1058] Add support for select entities in velbus (#87568) * Add support for select entities in velbus * Implement comments * EntityCategory is now in homeassistant.const * more comments --- .coveragerc | 1 + homeassistant/components/velbus/__init__.py | 1 + homeassistant/components/velbus/select.py | 47 +++++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 homeassistant/components/velbus/select.py diff --git a/.coveragerc b/.coveragerc index dfc13304b199..5b5096ee582c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1368,6 +1368,7 @@ omit = homeassistant/components/velbus/entity.py homeassistant/components/velbus/light.py homeassistant/components/velbus/sensor.py + homeassistant/components/velbus/select.py homeassistant/components/velbus/switch.py homeassistant/components/velux/__init__.py homeassistant/components/velux/cover.py diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index a51cef0a56c0..554b16877c72 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -34,6 +34,7 @@ PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.LIGHT, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py new file mode 100644 index 000000000000..af79b5d12769 --- /dev/null +++ b/homeassistant/components/velbus/select.py @@ -0,0 +1,47 @@ +"""Support for Velbus select.""" +from velbusaio.channels import SelectedProgram + +from homeassistant.components.select import SelectEntity +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 VelbusEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Velbus select based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] + cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + async_add_entities(VelbusSelect(channel) for channel in cntrl.get_all("select")) + + +class VelbusSelect(VelbusEntity, SelectEntity): + """Representation of a select option for velbus.""" + + _channel: SelectedProgram + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + channel: SelectedProgram, + ) -> None: + """Initialize a select Velbus entity.""" + super().__init__(channel) + self._attr_options = self._channel.get_options() + self._attr_unique_id = f"{self._attr_unique_id}-program_select" + + async def async_select_option(self, option: str) -> None: + """Update the program on the module.""" + await self._channel.set_selected_program(option) + + @property + def current_option(self) -> str: + """Return the selected option.""" + return self._channel.get_selected_program() From cc404cfe770cf15908423384203ca6d6444de049 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Tue, 28 Mar 2023 13:24:19 +0100 Subject: [PATCH 0885/1058] Refactor Tado to use entity descriptions and new naming style (#75750) * Refactor Tado to use entity descriptions and new naming style * minor fixes * typing --- .../components/tado/binary_sensor.py | 242 +++++++------- homeassistant/components/tado/climate.py | 16 +- homeassistant/components/tado/entity.py | 2 + homeassistant/components/tado/sensor.py | 312 ++++++++---------- 4 files changed, 274 insertions(+), 298 deletions(-) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 7f009c278fe3..24d62d760269 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -1,14 +1,21 @@ """Support for Tado sensors for each zone.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( DATA, @@ -24,31 +31,99 @@ from .entity import TadoDeviceEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) + +@dataclass +class TadoBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Any], bool] + + +@dataclass +class TadoBinarySensorEntityDescription( + BinarySensorEntityDescription, TadoBinarySensorEntityDescriptionMixin +): + """Describes Tado binary sensor entity.""" + + attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + + +BATTERY_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="battery state", + name="Battery state", + state_fn=lambda data: data["batteryState"] == "LOW", + device_class=BinarySensorDeviceClass.BATTERY, +) +CONNECTION_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="connection state", + name="Connection state", + state_fn=lambda data: data.get("connectionState", {}).get("value", False), + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) +POWER_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="power", + name="Power", + state_fn=lambda data: data.power == "ON", + device_class=BinarySensorDeviceClass.POWER, +) +LINK_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="link", + name="Link", + state_fn=lambda data: data.link == "ONLINE", + device_class=BinarySensorDeviceClass.CONNECTIVITY, +) +OVERLAY_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="overlay", + name="Overlay", + state_fn=lambda data: data.overlay_active, + attributes_fn=lambda data: {"termination": data.overlay_termination_type} + if data.overlay_active + else {}, + device_class=BinarySensorDeviceClass.POWER, +) +OPEN_WINDOW_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="open window", + name="Open window", + state_fn=lambda data: bool(data.open_window or data.open_window_detected), + attributes_fn=lambda data: data.open_window_attr, + device_class=BinarySensorDeviceClass.WINDOW, +) +EARLY_START_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( + key="early start", + name="Early start", + state_fn=lambda data: data.preparation, + device_class=BinarySensorDeviceClass.POWER, +) + DEVICE_SENSORS = { TYPE_BATTERY: [ - "battery state", - "connection state", + BATTERY_STATE_ENTITY_DESCRIPTION, + CONNECTION_STATE_ENTITY_DESCRIPTION, ], TYPE_POWER: [ - "connection state", + CONNECTION_STATE_ENTITY_DESCRIPTION, ], } ZONE_SENSORS = { TYPE_HEATING: [ - "power", - "link", - "overlay", - "early start", - "open window", + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, + OPEN_WINDOW_ENTITY_DESCRIPTION, + EARLY_START_ENTITY_DESCRIPTION, ], TYPE_AIR_CONDITIONING: [ - "power", - "link", - "overlay", - "open window", + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, + OPEN_WINDOW_ENTITY_DESCRIPTION, + ], + TYPE_HOT_WATER: [ + POWER_ENTITY_DESCRIPTION, + LINK_ENTITY_DESCRIPTION, + OVERLAY_ENTITY_DESCRIPTION, ], - TYPE_HOT_WATER: ["power", "link", "overlay"], } @@ -71,8 +146,8 @@ async def async_setup_entry( entities.extend( [ - TadoDeviceBinarySensor(tado, device, variable) - for variable in DEVICE_SENSORS[device_type] + TadoDeviceBinarySensor(tado, device, entity_description) + for entity_description in DEVICE_SENSORS[device_type] ] ) @@ -85,8 +160,8 @@ async def async_setup_entry( entities.extend( [ - TadoZoneBinarySensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS[zone_type] + TadoZoneBinarySensor(tado, zone["name"], zone["id"], entity_description) + for entity_description in ZONE_SENSORS[zone_type] ] ) @@ -96,16 +171,21 @@ async def async_setup_entry( class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, device_info, device_variable): + entity_description: TadoBinarySensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, tado, device_info, entity_description: TadoBinarySensorEntityDescription + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(device_info) - self.device_variable = device_variable - - self._unique_id = f"{device_variable} {self.device_id} {tado.home_id}" - - self._state = None + self._attr_unique_id = ( + f"{entity_description.key} {self.device_id} {tado.home_id}" + ) async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -121,30 +201,6 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): ) self._async_update_device_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.device_name} {self.device_variable}" - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this sensor.""" - if self.device_variable == "battery state": - return BinarySensorDeviceClass.BATTERY - if self.device_variable == "connection state": - return BinarySensorDeviceClass.CONNECTIVITY - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -159,29 +215,33 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): except KeyError: return - if self.device_variable == "battery state": - self._state = self._device_info["batteryState"] == "LOW" - elif self.device_variable == "connection state": - self._state = self._device_info.get("connectionState", {}).get( - "value", False + self._attr_is_on = self.entity_description.state_fn(self._device_info) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + self._device_info ) class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, zone_id, zone_variable): + entity_description: TadoBinarySensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + tado, + zone_name, + zone_id, + entity_description: TadoBinarySensorEntityDescription, + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) - self.zone_variable = zone_variable - - self._unique_id = f"{zone_variable} {zone_id} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_zone_data = None + self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -197,41 +257,6 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): ) self._async_update_zone_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.zone_name} {self.zone_variable}" - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this sensor.""" - if self.zone_variable == "early start": - return BinarySensorDeviceClass.POWER - if self.zone_variable == "link": - return BinarySensorDeviceClass.CONNECTIVITY - if self.zone_variable == "open window": - return BinarySensorDeviceClass.WINDOW - if self.zone_variable == "overlay": - return BinarySensorDeviceClass.POWER - if self.zone_variable == "power": - return BinarySensorDeviceClass.POWER - return None - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - @callback def _async_update_callback(self): """Update and write state.""" @@ -242,29 +267,12 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): def _async_update_zone_data(self): """Handle update callbacks.""" try: - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self._tado.data["zone"][self.zone_id] except KeyError: return - if self.zone_variable == "power": - self._state = self._tado_zone_data.power == "ON" - - elif self.zone_variable == "link": - self._state = self._tado_zone_data.link == "ONLINE" - - elif self.zone_variable == "overlay": - self._state = self._tado_zone_data.overlay_active - if self._tado_zone_data.overlay_active: - self._state_attributes = { - "termination": self._tado_zone_data.overlay_termination_type - } - - elif self.zone_variable == "early start": - self._state = self._tado_zone_data.preparation - - elif self.zone_variable == "open window": - self._state = bool( - self._tado_zone_data.open_window - or self._tado_zone_data.open_window_detected + self._attr_is_on = self.entity_description.state_fn(tado_zone_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_zone_data ) - self._state_attributes = self._tado_zone_data.open_window_attr diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index a72451b00231..cab3c42184e0 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -240,7 +240,11 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self.zone_id = zone_id self.zone_type = zone_type - self._unique_id = f"{zone_type} {zone_id} {tado.home_id}" + + self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" + self._attr_name = zone_name + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -288,16 +292,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) ) - @property - def name(self): - """Return the name of the entity.""" - return self.zone_name - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property def current_humidity(self): """Return the current humidity.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index 11de7ceb3143..c825bafc4b95 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -33,6 +33,8 @@ class TadoDeviceEntity(Entity): class TadoHomeEntity(Entity): """Base implementation for Tado home.""" + _attr_should_poll = False + def __init__(self, tado): """Initialize a Tado home.""" super().__init__() diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 4289813494a3..d218e9ca9337 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -1,9 +1,15 @@ """Support for Tado sensors for each zone.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass import logging +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -11,6 +17,7 @@ from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( CONDITIONS_MAP, @@ -25,26 +32,108 @@ from .entity import TadoHomeEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) -HOME_SENSORS = { - "outdoor temperature", - "solar percentage", - "weather condition", -} + +@dataclass +class TadoSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + state_fn: Callable[[Any], StateType] + + +@dataclass +class TadoSensorEntityDescription( + SensorEntityDescription, TadoSensorEntityDescriptionMixin +): + """Describes Tado sensor entity.""" + + attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + + +HOME_SENSORS = [ + TadoSensorEntityDescription( + key="outdoor temperature", + name="Outdoor temperature", + state_fn=lambda data: data["outsideTemperature"]["celsius"], + attributes_fn=lambda data: { + "time": data["outsideTemperature"]["timestamp"], + }, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TadoSensorEntityDescription( + key="solar percentage", + name="Solar percentage", + state_fn=lambda data: data["solarIntensity"]["percentage"], + attributes_fn=lambda data: { + "time": data["solarIntensity"]["timestamp"], + }, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + TadoSensorEntityDescription( + key="weather condition", + name="Weather condition", + state_fn=lambda data: format_condition(data["weatherState"]["value"]), + attributes_fn=lambda data: {"time": data["weatherState"]["timestamp"]}, + ), +] + +TEMPERATURE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="temperature", + name="Temperature", + state_fn=lambda data: data.current_temp, + attributes_fn=lambda data: { + "time": data.current_temp_timestamp, + "setting": 0, # setting is used in climate device + }, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, +) +HUMIDITY_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="humidity", + name="Humidity", + state_fn=lambda data: data.current_humidity, + attributes_fn=lambda data: {"time": data.current_humidity_timestamp}, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, +) +TADO_MODE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="tado mode", + name="Tado mode", + state_fn=lambda data: data.tado_mode, +) +HEATING_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="heating", + name="Heating", + state_fn=lambda data: data.heating_power_percentage, + attributes_fn=lambda data: {"time": data.heating_power_timestamp}, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, +) +AC_ENTITY_DESCRIPTION = TadoSensorEntityDescription( + key="ac", + name="AC", + state_fn=lambda data: data.ac_power, + attributes_fn=lambda data: {"time": data.ac_power_timestamp}, +) ZONE_SENSORS = { TYPE_HEATING: [ - "temperature", - "humidity", - "heating", - "tado mode", + TEMPERATURE_ENTITY_DESCRIPTION, + HUMIDITY_ENTITY_DESCRIPTION, + TADO_MODE_ENTITY_DESCRIPTION, + HEATING_ENTITY_DESCRIPTION, ], TYPE_AIR_CONDITIONING: [ - "temperature", - "humidity", - "ac", - "tado mode", + TEMPERATURE_ENTITY_DESCRIPTION, + HUMIDITY_ENTITY_DESCRIPTION, + TADO_MODE_ENTITY_DESCRIPTION, + AC_ENTITY_DESCRIPTION, ], - TYPE_HOT_WATER: ["tado mode"], + TYPE_HOT_WATER: [TADO_MODE_ENTITY_DESCRIPTION], } @@ -66,7 +155,12 @@ async def async_setup_entry( entities: list[SensorEntity] = [] # Create home sensors - entities.extend([TadoHomeSensor(tado, variable) for variable in HOME_SENSORS]) + entities.extend( + [ + TadoHomeSensor(tado, entity_description) + for entity_description in HOME_SENSORS + ] + ) # Create zone sensors for zone in zones: @@ -77,8 +171,8 @@ async def async_setup_entry( entities.extend( [ - TadoZoneSensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS[zone_type] + TadoZoneSensor(tado, zone["name"], zone["id"], entity_description) + for entity_description in ZONE_SENSORS[zone_type] ] ) @@ -88,18 +182,17 @@ async def async_setup_entry( class TadoHomeSensor(TadoHomeEntity, SensorEntity): """Representation of a Tado Sensor.""" - def __init__(self, tado, home_variable): + entity_description: TadoSensorEntityDescription + + _attr_has_entity_name = True + + def __init__(self, tado, entity_description: TadoSensorEntityDescription) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description super().__init__(tado) self._tado = tado - self.home_variable = home_variable - - self._unique_id = f"{home_variable} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_weather_data = self._tado.data["weather"] + self._attr_unique_id = f"{entity_description.key} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -115,50 +208,6 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): ) self._async_update_home_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._tado.home_name} {self.home_variable}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - if self.home_variable in ["temperature", "outdoor temperature"]: - return UnitOfTemperature.CELSIUS - if self.home_variable == "solar percentage": - return PERCENTAGE - if self.home_variable == "weather condition": - return None - - @property - def device_class(self): - """Return the device class.""" - if self.home_variable == "outdoor temperature": - return SensorDeviceClass.TEMPERATURE - return None - - @property - def state_class(self): - """Return the state class.""" - if self.home_variable in ["outdoor temperature", "solar percentage"]: - return SensorStateClass.MEASUREMENT - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -169,46 +218,37 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): def _async_update_home_data(self): """Handle update callbacks.""" try: - self._tado_weather_data = self._tado.data["weather"] + tado_weather_data = self._tado.data["weather"] except KeyError: return - if self.home_variable == "outdoor temperature": - self._state = self._tado_weather_data["outsideTemperature"]["celsius"] - self._state_attributes = { - "time": self._tado_weather_data["outsideTemperature"]["timestamp"], - } - - elif self.home_variable == "solar percentage": - self._state = self._tado_weather_data["solarIntensity"]["percentage"] - self._state_attributes = { - "time": self._tado_weather_data["solarIntensity"]["timestamp"], - } - - elif self.home_variable == "weather condition": - self._state = format_condition( - self._tado_weather_data["weatherState"]["value"] + self._attr_native_value = self.entity_description.state_fn(tado_weather_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_weather_data ) - self._state_attributes = { - "time": self._tado_weather_data["weatherState"]["timestamp"] - } class TadoZoneSensor(TadoZoneEntity, SensorEntity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, zone_id, zone_variable): + entity_description: TadoSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + tado, + zone_name, + zone_id, + entity_description: TadoSensorEntityDescription, + ) -> None: """Initialize of the Tado Sensor.""" + self.entity_description = entity_description self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) - self.zone_variable = zone_variable - - self._unique_id = f"{zone_variable} {zone_id} {tado.home_id}" - - self._state = None - self._state_attributes = None - self._tado_zone_data = None + self._attr_unique_id = f"{entity_description.key} {zone_id} {tado.home_id}" async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -224,54 +264,6 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): ) self._async_update_zone_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.zone_name} {self.zone_variable}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._state_attributes - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - if self.zone_variable == "temperature": - return UnitOfTemperature.CELSIUS - if self.zone_variable == "humidity": - return PERCENTAGE - if self.zone_variable == "heating": - return PERCENTAGE - if self.zone_variable == "ac": - return None - - @property - def device_class(self): - """Return the device class.""" - if self.zone_variable == "humidity": - return SensorDeviceClass.HUMIDITY - if self.zone_variable == "temperature": - return SensorDeviceClass.TEMPERATURE - return None - - @property - def state_class(self): - """Return the state class.""" - if self.zone_variable in ["heating", "humidity", "temperature"]: - return SensorStateClass.MEASUREMENT - return None - @callback def _async_update_callback(self): """Update and write state.""" @@ -282,32 +274,12 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): def _async_update_zone_data(self): """Handle update callbacks.""" try: - self._tado_zone_data = self._tado.data["zone"][self.zone_id] + tado_zone_data = self._tado.data["zone"][self.zone_id] except KeyError: return - if self.zone_variable == "temperature": - self._state = self._tado_zone_data.current_temp - self._state_attributes = { - "time": self._tado_zone_data.current_temp_timestamp, - "setting": 0, # setting is used in climate device - } - - elif self.zone_variable == "humidity": - self._state = self._tado_zone_data.current_humidity - self._state_attributes = { - "time": self._tado_zone_data.current_humidity_timestamp - } - - elif self.zone_variable == "heating": - self._state = self._tado_zone_data.heating_power_percentage - self._state_attributes = { - "time": self._tado_zone_data.heating_power_timestamp - } - - elif self.zone_variable == "ac": - self._state = self._tado_zone_data.ac_power - self._state_attributes = {"time": self._tado_zone_data.ac_power_timestamp} - - elif self.zone_variable == "tado mode": - self._state = self._tado_zone_data.tado_mode + self._attr_native_value = self.entity_description.state_fn(tado_zone_data) + if self.entity_description.attributes_fn is not None: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + tado_zone_data + ) From 8e7013b079761c8ef42a3c47945985cf68016dd0 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 28 Mar 2023 08:34:57 -0400 Subject: [PATCH 0886/1058] Add HTML support for Google Mail messages (#87201) --- homeassistant/components/google_mail/notify.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_mail/notify.py b/homeassistant/components/google_mail/notify.py index eba38c324916..974b2e4e4bfe 100644 --- a/homeassistant/components/google_mail/notify.py +++ b/homeassistant/components/google_mail/notify.py @@ -2,7 +2,7 @@ from __future__ import annotations import base64 -from email.message import EmailMessage +from email.mime.text import MIMEText from typing import Any from googleapiclient.http import HttpRequest @@ -43,8 +43,7 @@ class GMailNotificationService(BaseNotificationService): data: dict[str, Any] = kwargs.get(ATTR_DATA) or {} title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - email = EmailMessage() - email.set_content(message) + email = MIMEText(message, "html") if to_addrs := kwargs.get(ATTR_TARGET): email["To"] = ", ".join(to_addrs) email["From"] = data.get(ATTR_FROM, ATTR_ME) From d228df6d818f663b75a2efd9696d7507789b8a3e Mon Sep 17 00:00:00 2001 From: "Erik J. Olson" Date: Tue, 28 Mar 2023 07:56:10 -0500 Subject: [PATCH 0887/1058] Fix Notify Group payload data mis-merge (#90253) Co-authored-by: Erik Montnemery --- homeassistant/components/group/notify.py | 24 ++++++++++----------- tests/components/group/test_notify.py | 27 +++++++++++++++++++++--- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 7e8ce9236493..378a7852343b 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -32,18 +32,16 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def update(input_dict: dict[str, Any], update_source: dict[str, Any]) -> dict[str, Any]: - """Deep update a dictionary. - - Async friendly. - """ - for key, val in update_source.items(): +def add_defaults( + input_data: dict[str, Any], default_data: dict[str, Any] +) -> dict[str, Any]: + """Deep update a dictionary with default values.""" + for key, val in default_data.items(): if isinstance(val, Mapping): - recurse = update(input_dict.get(key, {}), val) # type: ignore[arg-type] - input_dict[key] = recurse - else: - input_dict[key] = update_source[key] - return input_dict + input_data[key] = add_defaults(input_data.get(key, {}), val) # type: ignore[arg-type] + elif key not in input_data: + input_data[key] = val + return input_data async def async_get_service( @@ -71,8 +69,8 @@ class GroupNotifyPlatform(BaseNotificationService): tasks: list[asyncio.Task[bool | None]] = [] for entity in self.entities: sending_payload = deepcopy(payload.copy()) - if (data := entity.get(ATTR_DATA)) is not None: - update(sending_payload, data) + if (default_data := entity.get(ATTR_DATA)) is not None: + add_defaults(sending_payload, default_data) tasks.append( asyncio.create_task( self.hass.services.async_call( diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index 6e4f9b503938..77569c80f0f3 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -54,14 +54,14 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None: "service": "demo2", "data": { "target": "unnamed device", - "data": {"test": "message"}, + "data": {"test": "message", "default": "default"}, }, }, ] }, ) - """Test sending a message with to a notify group.""" + """Test sending a message to a notify group.""" await service.async_send_message( "Hello", title="Test notification", data={"hello": "world"} ) @@ -77,7 +77,28 @@ async def test_send_message_with_data(hass: HomeAssistant) -> None: assert service2.send_message.mock_calls[0][2] == { "target": ["unnamed device"], "title": "Test notification", - "data": {"hello": "world", "test": "message"}, + "data": {"hello": "world", "test": "message", "default": "default"}, + } + + """Test sending a message which overrides service defaults to a notify group.""" + await service.async_send_message( + "Hello", + title="Test notification", + data={"hello": "world", "default": "override"}, + ) + + await hass.async_block_till_done() + + assert service1.send_message.mock_calls[1][1][0] == "Hello" + assert service1.send_message.mock_calls[1][2] == { + "title": "Test notification", + "data": {"hello": "world", "default": "override"}, + } + assert service2.send_message.mock_calls[1][1][0] == "Hello" + assert service2.send_message.mock_calls[1][2] == { + "target": ["unnamed device"], + "title": "Test notification", + "data": {"hello": "world", "test": "message", "default": "override"}, } From 091932c3acd984465d4f2a474b8943e649c8fbd1 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Tue, 28 Mar 2023 12:59:03 +0000 Subject: [PATCH 0888/1058] Improve airq test coverage (#90192) * Add a missing test for aborting with "already_configured" Test that config_flow aborts with "already_configured" when the integration has already been configured * Don't copy test data Since #90232 is merged, it is no longer needed * Split the initialisation into two steps, as it should be --- tests/components/airq/test_config_flow.py | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index af71dc813e20..252c12f80fac 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -11,6 +11,8 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("mock_setup_entry") TEST_USER_DATA = { @@ -91,3 +93,25 @@ async def test_form_invalid_input(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "invalid_input"} + + +async def test_duplicate_error(hass: HomeAssistant) -> None: + """Test that errors are shown when duplicates are added.""" + MockConfigEntry( + data=TEST_USER_DATA, + domain=DOMAIN, + unique_id=TEST_DEVICE_INFO["id"], + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("aioairq.AirQ.validate"), patch( + "aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" From cdefc48fcdbaa70674a91f77ed5a0cb4112afe50 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Mar 2023 07:07:09 -0600 Subject: [PATCH 0889/1058] Add panel brightness control for Litter-Robot 4 (#86269) * Add panel brightness control for Litter-Robot 4 * Use translation_key * Fix test --- .../components/litterrobot/manifest.json | 2 +- .../components/litterrobot/select.py | 94 ++++++++++++------- .../components/litterrobot/strings.json | 9 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/litterrobot/test_select.py | 41 +++++++- 6 files changed, 111 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index e635e80a6e92..0b162ee2e56d 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.1.1"] + "requirements": ["pylitterbot==2023.1.2"] } diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index bc1613f1c28d..feac85ecac48 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -3,10 +3,10 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -import itertools from typing import Any, Generic, TypeVar -from pylitterbot import FeederRobot, LitterRobot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot4, Robot +from pylitterbot.robot.litterrobot4 import BrightnessLevel from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -18,14 +18,21 @@ from .const import DOMAIN from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -_CastTypeT = TypeVar("_CastTypeT", int, float) +_CastTypeT = TypeVar("_CastTypeT", int, float, str) + +BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = { + BrightnessLevel.LOW: "mdi:lightbulb-on-30", + BrightnessLevel.MEDIUM: "mdi:lightbulb-on-50", + BrightnessLevel.HIGH: "mdi:lightbulb-on", + None: "mdi:lightbulb-question", +} @dataclass class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): """A class that describes robot select entity required keys.""" - current_fn: Callable[[_RobotT], _CastTypeT] + current_fn: Callable[[_RobotT], _CastTypeT | None] options_fn: Callable[[_RobotT], list[_CastTypeT]] select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] @@ -37,26 +44,42 @@ class RobotSelectEntityDescription( """A class that describes robot select entities.""" entity_category: EntityCategory = EntityCategory.CONFIG + icon_fn: Callable[[_RobotT], str] | None = None -LITTER_ROBOT_SELECT = RobotSelectEntityDescription[LitterRobot, int]( - key="cycle_delay", - name="Clean cycle wait time minutes", - icon="mdi:timer-outline", - unit_of_measurement=UnitOfTime.MINUTES, - current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, - options_fn=lambda robot: robot.VALID_WAIT_TIMES, - select_fn=lambda robot, option: robot.set_wait_time(int(option)), -) -FEEDER_ROBOT_SELECT = RobotSelectEntityDescription[FeederRobot, float]( - key="meal_insert_size", - name="Meal insert size", - icon="mdi:scale", - unit_of_measurement="cups", - current_fn=lambda robot: robot.meal_insert_size, - options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, - select_fn=lambda robot, option: robot.set_meal_insert_size(float(option)), -) +ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = { + LitterRobot: RobotSelectEntityDescription[LitterRobot, int]( + key="cycle_delay", + name="Clean cycle wait time minutes", + icon="mdi:timer-outline", + unit_of_measurement=UnitOfTime.MINUTES, + current_fn=lambda robot: robot.clean_cycle_wait_time_minutes, + options_fn=lambda robot: robot.VALID_WAIT_TIMES, + select_fn=lambda robot, opt: robot.set_wait_time(int(opt)), + ), + LitterRobot4: RobotSelectEntityDescription[LitterRobot4, str]( + key="panel_brightness", + name="Panel brightness", + translation_key="brightness_level", + current_fn=lambda robot: bri.name.lower() + if (bri := robot.panel_brightness) is not None + else None, + options_fn=lambda _: [level.name.lower() for level in BrightnessLevel], + select_fn=lambda robot, opt: robot.set_panel_brightness( + BrightnessLevel[opt.upper()] + ), + icon_fn=lambda robot: BRIGHTNESS_LEVEL_ICON_MAP[robot.panel_brightness], + ), + FeederRobot: RobotSelectEntityDescription[FeederRobot, float]( + key="meal_insert_size", + name="Meal insert size", + icon="mdi:scale", + unit_of_measurement="cups", + current_fn=lambda robot: robot.meal_insert_size, + options_fn=lambda robot: robot.VALID_MEAL_INSERT_SIZES, + select_fn=lambda robot, opt: robot.set_meal_insert_size(float(opt)), + ), +} async def async_setup_entry( @@ -66,22 +89,16 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot selects using config entry.""" hub: LitterRobotHub = hass.data[DOMAIN][config_entry.entry_id] - entities: list[LitterRobotSelect] = list( - itertools.chain( - ( - LitterRobotSelect(robot=robot, hub=hub, description=LITTER_ROBOT_SELECT) - for robot in hub.litter_robots() - ), - ( - LitterRobotSelect(robot=robot, hub=hub, description=FEEDER_ROBOT_SELECT) - for robot in hub.feeder_robots() - ), - ) - ) + entities = [ + LitterRobotSelectEntity(robot=robot, hub=hub, description=description) + for robot in hub.account.robots + for robot_type, description in ROBOT_SELECT_MAP.items() + if isinstance(robot, robot_type) + ] async_add_entities(entities) -class LitterRobotSelect( +class LitterRobotSelectEntity( LitterRobotEntity[_RobotT], SelectEntity, Generic[_RobotT, _CastTypeT] ): """Litter-Robot Select.""" @@ -99,6 +116,13 @@ class LitterRobotSelect( options = self.entity_description.options_fn(self.robot) self._attr_options = list(map(str, options)) + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + if icon_fn := self.entity_description.icon_fn: + return str(icon_fn(self.robot)) + return super().icon + @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 2d40eb6a0448..b4aa8f0016d6 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -62,6 +62,15 @@ "spf": "Pinch Detect At Startup" } } + }, + "select": { + "brightness_level": { + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + } } } } diff --git a/requirements_all.txt b/requirements_all.txt index 1560012227f8..46599fbafe7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1753,7 +1753,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.1.1 +pylitterbot==2023.1.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1235f47340c..e94cabd6ac03 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1269,7 +1269,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.1.1 +pylitterbot==2023.1.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index 478d801e4dd3..f6a32a6ef35f 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -1,9 +1,12 @@ """Test the Litter-Robot select entity.""" -from pylitterbot import LitterRobot3 +from unittest.mock import AsyncMock, MagicMock + +from pylitterbot import LitterRobot3, LitterRobot4 import pytest from homeassistant.components.select import ( ATTR_OPTION, + ATTR_OPTIONS, DOMAIN as PLATFORM_DOMAIN, SERVICE_SELECT_OPTION, ) @@ -14,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import setup_integration SELECT_ENTITY_ID = "select.test_clean_cycle_wait_time_minutes" +PANEL_BRIGHTNESS_ENTITY_ID = "select.test_panel_brightness" async def test_wait_time_select( @@ -63,3 +67,38 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No blocking=True, ) assert not mock_account.robots[0].set_wait_time.called + + +async def test_panel_brightness_select( + hass: HomeAssistant, + mock_account_with_litterrobot_4: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Tests the wait time select entity.""" + await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + + select = hass.states.get(PANEL_BRIGHTNESS_ENTITY_ID) + assert select + assert len(select.attributes[ATTR_OPTIONS]) == 3 + + entity_entry = entity_registry.async_get(PANEL_BRIGHTNESS_ENTITY_ID) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.CONFIG + + data = {ATTR_ENTITY_ID: PANEL_BRIGHTNESS_ENTITY_ID} + + robot: LitterRobot4 = mock_account_with_litterrobot_4.robots[0] + robot.set_panel_brightness = AsyncMock(return_value=True) + count = 0 + for option in select.attributes[ATTR_OPTIONS]: + count += 1 + data[ATTR_OPTION] = option + + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SELECT_OPTION, + data, + blocking=True, + ) + + assert robot.set_panel_brightness.call_count == count From 2123600039cfbfe073d719aeaad26589213cdd7e Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 28 Mar 2023 09:10:28 -0400 Subject: [PATCH 0890/1058] Add minutely updates to relative_time and today_at template functions (#86815) * add minutely update * fix mypy --- homeassistant/helpers/template.py | 20 ++++++++++++++------ tests/helpers/test_template.py | 13 ++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8f68c7af3787..f21cfc08f142 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1947,8 +1947,11 @@ def random_every_time(context, values): return random.choice(values) -def today_at(time_str: str = "") -> datetime: +def today_at(hass: HomeAssistant, time_str: str = "") -> datetime: """Record fetching now where the time has been replaced with value.""" + if (render_info := hass.data.get(_RENDER_INFO)) is not None: + render_info.has_time = True + today = dt_util.start_of_local_day() if not time_str: return today @@ -1961,7 +1964,7 @@ def today_at(time_str: str = "") -> datetime: return datetime.combine(today, time_today, today.tzinfo) -def relative_time(value): +def relative_time(hass: HomeAssistant, value: Any) -> Any: """Take a datetime and return its "age" as a string. The age can be in second, minute, hour, day, month or year. Only the @@ -1971,6 +1974,9 @@ def relative_time(value): If the input are not a datetime object the input will be returned unmodified. """ + if (render_info := hass.data.get(_RENDER_INFO)) is not None: + render_info.has_time = True + if not isinstance(value, datetime): return value if not value.tzinfo: @@ -2152,7 +2158,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["as_datetime"] = as_datetime self.filters["as_timedelta"] = as_timedelta self.filters["as_timestamp"] = forgiving_as_timestamp - self.filters["today_at"] = today_at self.filters["as_local"] = dt_util.as_local self.filters["timestamp_custom"] = timestamp_custom self.filters["timestamp_local"] = timestamp_local @@ -2178,7 +2183,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["is_number"] = is_number self.filters["float"] = forgiving_float_filter self.filters["int"] = forgiving_int_filter - self.filters["relative_time"] = relative_time self.filters["slugify"] = slugify self.filters["iif"] = iif self.filters["bool"] = forgiving_boolean @@ -2201,8 +2205,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["as_local"] = dt_util.as_local self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp - self.globals["today_at"] = today_at - self.globals["relative_time"] = relative_time self.globals["timedelta"] = timedelta self.globals["strptime"] = strptime self.globals["urlencode"] = urlencode @@ -2307,6 +2309,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "device_id", "area_id", "area_name", + "relative_time", + "today_at", ] hass_filters = ["closest", "expand", "device_id", "area_id", "area_name"] for glob in hass_globals: @@ -2330,6 +2334,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["states"] = self.globals["states"] self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) + self.globals["relative_time"] = hassfunction(relative_time) + self.filters["relative_time"] = self.globals["relative_time"] + self.globals["today_at"] = hassfunction(today_at) + self.filters["today_at"] = self.globals["today_at"] def is_safe_callable(self, obj): """Test if callback is safe.""" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 750602c9d6c9..c9ef9494bf17 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1696,6 +1696,11 @@ def test_today_at( with pytest.raises(TemplateError): template.Template("{{ today_at('bad') }}", hass).async_render() + info = template.Template( + "{{ today_at('10:00').isoformat() }}", hass + ).async_render_to_info() + assert info.has_time is True + freezer.stop() @@ -1707,9 +1712,12 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" hass.config.set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") + relative_time_template = ( + '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' + ) with patch("homeassistant.util.dt.now", return_value=now): result = template.Template( - '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}', + relative_time_template, hass, ).async_render() assert result == "1 hour" @@ -1768,6 +1776,9 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: ).async_render() assert result == "string" + info = template.Template(relative_time_template, hass).async_render_to_info() + assert info.has_time is True + @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", From b4775ed2eba3826dcfbc0b03c2b5476ccaaacaa4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 15:22:48 +0200 Subject: [PATCH 0891/1058] Don't rely on the demo integration in voice_assistant tests (#90405) --- .../snapshots/test_websocket.ambr | 2 +- .../voice_assistant/test_websocket.py | 86 ++++++++++++++----- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/tests/components/voice_assistant/snapshots/test_websocket.ambr b/tests/components/voice_assistant/snapshots/test_websocket.ambr index 07934df6c4c6..c18af44b21cf 100644 --- a/tests/components/voice_assistant/snapshots/test_websocket.ambr +++ b/tests/components/voice_assistant/snapshots/test_websocket.ambr @@ -66,7 +66,7 @@ dict({ 'tts_output': dict({ 'mime_type': 'audio/mpeg', - 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_demo.mp3', + 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en_-_test.mp3', }), }) # --- diff --git a/tests/components/voice_assistant/test_websocket.py b/tests/components/voice_assistant/test_websocket.py index f02122a3e7fa..149d896dcf6b 100644 --- a/tests/components/voice_assistant/test_websocket.py +++ b/tests/components/voice_assistant/test_websocket.py @@ -1,15 +1,18 @@ """Websocket tests for Voice Assistant integration.""" import asyncio from collections.abc import AsyncIterable +from typing import Any from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import stt +from homeassistant.components import stt, tts from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component +from tests.common import MockModule, mock_integration, mock_platform from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import mock_get_cache_files, mock_init_cache_dir, @@ -64,6 +67,61 @@ class MockSttProvider(stt.Provider): return stt.SpeechResult(self.text, stt.SpeechResultState.SUCCESS) +class MockSTT: + """A mock STT platform.""" + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> tts.Provider: + """Set up a mock speech component.""" + return MockSttProvider(hass, _TRANSCRIPT) + + +class MockTTSProvider(tts.Provider): + """Mock TTS provider.""" + + name = "Test" + + @property + def default_language(self) -> str: + """Return the default language.""" + return "en" + + @property + def supported_languages(self) -> list[str]: + """Return list of supported languages.""" + return ["en"] + + @property + def supported_options(self) -> list[str]: + """Return list of supported options like voice, emotions.""" + return ["voice", "age"] + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] | None = None + ) -> tts.TtsAudioType: + """Load TTS dat.""" + return ("mp3", b"") + + +class MockTTS: + """A mock TTS platform.""" + + PLATFORM_SCHEMA = tts.PLATFORM_SCHEMA + + async def async_get_engine( + self, + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, + ) -> tts.Provider: + """Set up a mock speech component.""" + return MockTTSProvider() + + @pytest.fixture(autouse=True) async def init_components( hass: HomeAssistant, @@ -71,29 +129,15 @@ async def init_components( mock_init_cache_dir, # noqa: F811 ): """Initialize relevant components with empty configs.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform(hass, "test.tts", MockTTS()) + mock_platform(hass, "test.stt", MockSTT()) + + assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) + assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "test"}}) assert await async_setup_component(hass, "media_source", {}) - assert await async_setup_component( - hass, - "tts", - { - "tts": { - "platform": "demo", - } - }, - ) - assert await async_setup_component(hass, "stt", {}) - - # mock_platform fails because it can't import - hass.data[stt.DOMAIN] = {"test": MockSttProvider(hass, _TRANSCRIPT)} - assert await async_setup_component(hass, "voice_assistant", {}) - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=("mp3", b""), - ) as mock_get_tts: - yield mock_get_tts - async def test_text_only_pipeline( hass: HomeAssistant, From 02e2e4d0395d6000211c5142e1437dd18f803bad Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:29:24 +0200 Subject: [PATCH 0892/1058] Add rest encoding test (#90404) * Add rest encoding test * docstring --- tests/components/rest/test_sensor.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 46a972628e5c..5ae8530c2959 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -106,6 +106,31 @@ async def test_setup_minimum(hass: HomeAssistant) -> None: assert len(hass.states.async_all("sensor")) == 1 +@respx.mock +async def test_setup_encoding(hass: HomeAssistant) -> None: + """Test setup with non-utf8 encoding.""" + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + stream=httpx.ByteStream("tack själv".encode(encoding="iso-8859-1")), + ) + assert await async_setup_component( + hass, + DOMAIN, + { + "sensor": { + "name": "mysensor", + "encoding": "iso-8859-1", + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 1 + assert hass.states.get("sensor.mysensor").state == "tack själv" + + @respx.mock async def test_manual_update(hass: HomeAssistant) -> None: """Test setup with minimum configuration.""" From bdf29b594f6b45b0cd759654b87f7d495e3c6a66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:32:39 +0200 Subject: [PATCH 0893/1058] Replace comments with docstring in ColorMode enum (#90408) --- homeassistant/components/light/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 02f6e44a7008..0c3a711a7387 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -68,16 +68,20 @@ ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes" class ColorMode(StrEnum): """Possible light color modes.""" - UNKNOWN = "unknown" # Ambiguous color mode - ONOFF = "onoff" # Must be the only supported mode - BRIGHTNESS = "brightness" # Must be the only supported mode + UNKNOWN = "unknown" + """Ambiguous color mode""" + ONOFF = "onoff" + """Must be the only supported mode""" + BRIGHTNESS = "brightness" + """Must be the only supported mode""" COLOR_TEMP = "color_temp" HS = "hs" XY = "xy" RGB = "rgb" RGBW = "rgbw" RGBWW = "rgbww" - WHITE = "white" # Must *NOT* be the only supported mode + WHITE = "white" + """Must *NOT* be the only supported mode""" # These COLOR_MODE_* constants are deprecated as of Home Assistant 2022.5. From 3662c651c9511a2081bf1d85e79b627a3c8c1eaa Mon Sep 17 00:00:00 2001 From: b-uwe <61052367+b-uwe@users.noreply.github.com> Date: Tue, 28 Mar 2023 16:37:57 +0200 Subject: [PATCH 0894/1058] Add brand for HomeSeer (#90066) --- homeassistant/brands/homeseer.json | 5 +++++ homeassistant/generated/integrations.json | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 homeassistant/brands/homeseer.json diff --git a/homeassistant/brands/homeseer.json b/homeassistant/brands/homeseer.json new file mode 100644 index 000000000000..cfc36968c15c --- /dev/null +++ b/homeassistant/brands/homeseer.json @@ -0,0 +1,5 @@ +{ + "domain": "homeseer", + "name": "HomeSeer", + "iot_standards": ["zwave"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1fb801be1248..3e89f9d12d5a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2305,6 +2305,12 @@ } } }, + "homeseer": { + "name": "HomeSeer", + "iot_standards": [ + "zwave" + ] + }, "homewizard": { "name": "HomeWizard Energy", "integration_type": "hub", From abe60375b378b0f7812348b3ba0f446bb3fd269b Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Tue, 28 Mar 2023 10:43:47 -0400 Subject: [PATCH 0895/1058] Disable esphome stopping a cover if the cover doesn't support stopping (#80104) * Make "CoverEntityFeature.STOP" conditional * Check APIVersion before checking false by default flag * sort --------- Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- homeassistant/components/esphome/cover.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 99d5f16b271b..9d82b2852916 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from aioesphomeapi import CoverInfo, CoverOperation, CoverState +from aioesphomeapi import APIVersion, CoverInfo, CoverOperation, CoverState from homeassistant.components.cover import ( ATTR_POSITION, @@ -41,9 +41,10 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" - flags = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) + flags = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + if self._api_version < APIVersion(1, 8) or self._static_info.supports_stop: + flags |= CoverEntityFeature.STOP if self._static_info.supports_position: flags |= CoverEntityFeature.SET_POSITION if self._static_info.supports_tilt: From a26d95ec02ede0da684b2389d6cc67c0144eafc2 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 28 Mar 2023 16:45:06 +0200 Subject: [PATCH 0896/1058] Add switch tests for devolo_home_control (#80154) --- .coveragerc | 1 - tests/components/devolo_home_control/mocks.py | 23 +++++ .../devolo_home_control/test_switch.py | 86 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/components/devolo_home_control/test_switch.py diff --git a/.coveragerc b/.coveragerc index 5b5096ee582c..507e0dc89536 100644 --- a/.coveragerc +++ b/.coveragerc @@ -198,7 +198,6 @@ omit = homeassistant/components/denonavr/__init__.py homeassistant/components/denonavr/media_player.py homeassistant/components/denonavr/receiver.py - homeassistant/components/devolo_home_control/switch.py homeassistant/components/digital_ocean/* homeassistant/components/discogs/sensor.py homeassistant/components/discord/__init__.py diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index 0fc01d61841f..aef687936e4f 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -42,6 +42,7 @@ class BinarySwitchPropertyMock(BinarySwitchProperty): """Initialize the mock.""" self._logger = MagicMock() self.element_uid = "Test" + self.state = False class ConsumptionPropertyMock(ConsumptionProperty): @@ -233,6 +234,17 @@ class SensorMock(DeviceMock): } +class SwitchMock(DeviceMock): + """devolo Home Control switch device mock.""" + + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.binary_switch_property = { + "devolo.BinarySwitch:Test": BinarySwitchPropertyMock() + } + + class HomeControlMock(HomeControl): """devolo Home Control gateway mock.""" @@ -353,3 +365,14 @@ class HomeControlMockSiren(HomeControlMock): self.devices = {"Test": SirenMock()} self.publisher = Publisher(self.devices.keys()) self.publisher.unregister = MagicMock() + + +class HomeControlMockSwitch(HomeControlMock): + """devolo Home Control gateway mock with switch device.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = {"Test": SwitchMock()} + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() diff --git a/tests/components/devolo_home_control/test_switch.py b/tests/components/devolo_home_control/test_switch.py new file mode 100644 index 000000000000..62de9038483e --- /dev/null +++ b/tests/components/devolo_home_control/test_switch.py @@ -0,0 +1,86 @@ +"""Tests for the devolo Home Control switch platform.""" +from unittest.mock import patch + +from homeassistant.components.switch import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration +from .mocks import HomeControlMock, HomeControlMockSwitch + + +async def test_switch(hass: HomeAssistant): + """Test setup and state change of a switch device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSwitch() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + assert state.state == STATE_OFF + + # Emulate websocket message: switched on + test_gateway.devices["Test"].binary_switch_property[ + "devolo.BinarySwitch:Test" + ].state = True + test_gateway.publisher.dispatch("Test", ("devolo.BinarySwitch:Test", True)) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_ON + + with patch( + "devolo_home_control_api.properties.binary_switch_property.BinarySwitchProperty.set" + ) as set_value: + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(state=True) + + set_value.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(state=False) + + # Emulate websocket message: device went offline + test_gateway.devices["Test"].status = 1 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + + +async def test_remove_from_hass(hass: HomeAssistant): + """Test removing entity.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockSwitch() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + assert test_gateway.publisher.unregister.call_count == 1 From 866518c5a0877f73bd48620655d8f468309abc9e Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 28 Mar 2023 10:49:32 -0400 Subject: [PATCH 0897/1058] Add tests to Lidarr (#79610) * Add tests to Lidarr * fix js files * take out the trash * fix 3.9 * uno mas * fix fixture * ruff * Update const.py --------- Co-authored-by: Erik Montnemery --- .coveragerc | 3 - .prettierignore | 2 + homeassistant/components/lidarr/const.py | 2 - tests/components/lidarr/__init__.py | 52 ------- tests/components/lidarr/conftest.py | 142 ++++++++++++++++++ .../lidarr/fixtures/initialize-wrong.js | 12 ++ .../components/lidarr/fixtures/initialize.js | 12 ++ tests/components/lidarr/fixtures/queue.json | 57 +++++++ .../lidarr/fixtures/rootfolder-linux.json | 15 ++ .../lidarr/fixtures/system-status.json | 4 +- .../lidarr/fixtures/wanted-missing.json | 134 +++++++++++++++++ tests/components/lidarr/test_config_flow.py | 130 ++++++---------- tests/components/lidarr/test_init.py | 61 ++++++++ tests/components/lidarr/test_sensor.py | 33 ++++ 14 files changed, 518 insertions(+), 141 deletions(-) create mode 100644 tests/components/lidarr/conftest.py create mode 100644 tests/components/lidarr/fixtures/initialize-wrong.js create mode 100644 tests/components/lidarr/fixtures/initialize.js create mode 100644 tests/components/lidarr/fixtures/queue.json create mode 100644 tests/components/lidarr/fixtures/rootfolder-linux.json create mode 100644 tests/components/lidarr/fixtures/wanted-missing.json create mode 100644 tests/components/lidarr/test_init.py create mode 100644 tests/components/lidarr/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 507e0dc89536..82677177e642 100644 --- a/.coveragerc +++ b/.coveragerc @@ -629,9 +629,6 @@ omit = homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py - homeassistant/components/lidarr/__init__.py - homeassistant/components/lidarr/coordinator.py - homeassistant/components/lidarr/sensor.py homeassistant/components/life360/__init__.py homeassistant/components/life360/coordinator.py homeassistant/components/life360/device_tracker.py diff --git a/.prettierignore b/.prettierignore index a4d1d99079dc..aab23e230789 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ azure-*.yml docs/source/_templates/* homeassistant/components/*/translations/*.json homeassistant/generated/* +tests/components/lidarr/fixtures/initialize.js +tests/components/lidarr/fixtures/initialize-wrong.js diff --git a/homeassistant/components/lidarr/const.py b/homeassistant/components/lidarr/const.py index feadedb6d496..ccf56db802e8 100644 --- a/homeassistant/components/lidarr/const.py +++ b/homeassistant/components/lidarr/const.py @@ -17,8 +17,6 @@ BYTE_SIZES = [ ] # Defaults -DEFAULT_DAYS = "1" -DEFAULT_HOST = "localhost" DEFAULT_NAME = "Lidarr" DEFAULT_UNIT = UnitOfInformation.GIGABYTES DEFAULT_MAX_RECORDS = 20 diff --git a/tests/components/lidarr/__init__.py b/tests/components/lidarr/__init__.py index 8c1220e4c6ce..6c1042e10de5 100644 --- a/tests/components/lidarr/__init__.py +++ b/tests/components/lidarr/__init__.py @@ -1,53 +1 @@ """Tests for the Lidarr component.""" -from aiopyarr.lidarr_client import LidarrClient - -from homeassistant.components.lidarr.const import DOMAIN -from homeassistant.const import ( - CONF_API_KEY, - CONF_URL, - CONF_VERIFY_SSL, - CONTENT_TYPE_JSON, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from tests.common import MockConfigEntry, load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker - -BASE_PATH = "" -API_KEY = "1234567890abcdef1234567890abcdef" -URL = "http://127.0.0.1:8686" -client = LidarrClient(session=async_get_clientsession, api_token=API_KEY, url=URL) -API_URL = f"{URL}/api/{client._host.api_ver}" - -MOCK_REAUTH_INPUT = {CONF_API_KEY: "new_key"} - -MOCK_USER_INPUT = { - CONF_URL: URL, - CONF_VERIFY_SSL: False, -} - -CONF_DATA = MOCK_USER_INPUT | {CONF_API_KEY: API_KEY} - - -def mock_connection( - aioclient_mock: AiohttpClientMocker, - url: str = API_URL, -) -> None: - """Mock lidarr connection.""" - aioclient_mock.get( - f"{url}/system/status", - text=load_fixture("lidarr/system-status.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - -def create_entry(hass: HomeAssistant) -> MockConfigEntry: - """Create Efergy entry in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=CONF_DATA, - ) - - entry.add_to_hass(hass) - return entry diff --git a/tests/components/lidarr/conftest.py b/tests/components/lidarr/conftest.py new file mode 100644 index 000000000000..308de36954e5 --- /dev/null +++ b/tests/components/lidarr/conftest.py @@ -0,0 +1,142 @@ +"""Configure pytest for Lidarr tests.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Generator +from http import HTTPStatus + +from aiohttp.client_exceptions import ClientError +from aiopyarr.lidarr_client import LidarrClient +import pytest + +from homeassistant.components.lidarr.const import DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_URL, + CONF_VERIFY_SSL, + CONTENT_TYPE_JSON, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +URL = "http://127.0.0.1:8668" +API_KEY = "1234567890abcdef1234567890abcdef" +client = LidarrClient(session=async_get_clientsession, api_token=API_KEY, url=URL) +API_URL = f"{URL}/api/{client._host.api_ver}" + +MOCK_INPUT = {CONF_URL: URL, CONF_VERIFY_SSL: False} + +CONF_DATA = MOCK_INPUT | {CONF_API_KEY: API_KEY} + +ComponentSetup = Callable[[], Awaitable[None]] + + +def mock_error( + aioclient_mock: AiohttpClientMocker, status: HTTPStatus | None = None +) -> None: + """Mock an error.""" + if status: + aioclient_mock.get(f"{API_URL}/queue", status=status) + aioclient_mock.get(f"{API_URL}/rootfolder", status=status) + aioclient_mock.get(f"{API_URL}/system/status", status=status) + aioclient_mock.get(f"{API_URL}/wanted/missing", status=status) + aioclient_mock.get(f"{API_URL}/queue", exc=ClientError) + aioclient_mock.get(f"{API_URL}/rootfolder", exc=ClientError) + aioclient_mock.get(f"{API_URL}/system/status", exc=ClientError) + aioclient_mock.get(f"{API_URL}/wanted/missing", exc=ClientError) + + +@pytest.fixture +def cannot_connect(aioclient_mock: AiohttpClientMocker) -> None: + """Mock cannot connect error.""" + mock_error(aioclient_mock, status=HTTPStatus.INTERNAL_SERVER_ERROR) + + +@pytest.fixture +def invalid_auth(aioclient_mock: AiohttpClientMocker) -> None: + """Mock invalid authorization error.""" + mock_error(aioclient_mock, status=HTTPStatus.UNAUTHORIZED) + + +@pytest.fixture +def wrong_app(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr wrong app.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text=load_fixture("lidarr/initialize-wrong.js"), + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture +def zeroconf_failed(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr zero configuration failure.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text="login-failed", + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture +def unknown(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr unknown error.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text="something went wrong", + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture(name="connection") +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Lidarr connection.""" + aioclient_mock.get( + f"{URL}/initialize.js", + text=load_fixture("lidarr/initialize.js"), + headers={"Content-Type": "application/javascript"}, + ) + aioclient_mock.get( + f"{API_URL}/system/status", + text=load_fixture("lidarr/system-status.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"{API_URL}/queue", + text=load_fixture("lidarr/queue.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"{API_URL}/wanted/missing", + text=load_fixture("lidarr/wanted-missing.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.get( + f"{API_URL}/rootfolder", + text=load_fixture("lidarr/rootfolder-linux.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create Lidarr entry in Home Assistant.""" + return MockConfigEntry(domain=DOMAIN, data=CONF_DATA) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> Generator[ComponentSetup, None, None]: + """Set up the lidarr integration in Home Assistant.""" + config_entry.add_to_hass(hass) + + async def func() -> None: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return func diff --git a/tests/components/lidarr/fixtures/initialize-wrong.js b/tests/components/lidarr/fixtures/initialize-wrong.js new file mode 100644 index 000000000000..9d92f564da89 --- /dev/null +++ b/tests/components/lidarr/fixtures/initialize-wrong.js @@ -0,0 +1,12 @@ +window.Radarr = { + apiRoot: '/api/v3', + apiKey: '1234567890abcdef1234567890abcdef', + release: '4.0.3.5849-develop', + version: '4.0.3.5849', + instanceName: 'Radarr', + branch: 'nightly', + analytics: true, + userHash: 'abcd1234', + urlBase: '', + isProduction: true + }; \ No newline at end of file diff --git a/tests/components/lidarr/fixtures/initialize.js b/tests/components/lidarr/fixtures/initialize.js new file mode 100644 index 000000000000..d50aaf9cc646 --- /dev/null +++ b/tests/components/lidarr/fixtures/initialize.js @@ -0,0 +1,12 @@ +window.Lidarr = { + apiRoot: '/api/v1', + apiKey: '1234567890abcdef1234567890abcdef', + release: '10.0.0.34882-develop', + version: '10.0.0.34882', + instanceName: 'Lidarr', + branch: 'nightly', + analytics: true, + userHash: 'abcd1234', + urlBase: '', + isProduction: true + }; \ No newline at end of file diff --git a/tests/components/lidarr/fixtures/queue.json b/tests/components/lidarr/fixtures/queue.json new file mode 100644 index 000000000000..24a922b97a51 --- /dev/null +++ b/tests/components/lidarr/fixtures/queue.json @@ -0,0 +1,57 @@ +{ + "page": 1, + "pageSize": 20, + "sortKey": "timeleft", + "sortDirection": "default", + "totalRecords": 2, + "records": [ + { + "artistId": 1, + "albumId": 1, + "quality": { + "quality": { "id": 0, "name": "Unknown" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + }, + "size": 1000000, + "title": "string", + "sizeleft": 100000, + "timeleft": "00:00:00", + "estimatedCompletionTime": "2020-09-26T18:47:46Z", + "status": "downloading", + "trackedDownloadStatus": "ok", + "trackedDownloadState": "downloading", + "statusMessages": [], + "downloadId": "string", + "protocol": "string", + "downloadClient": "testclient", + "indexer": "test", + "outputPath": "/downloads/string", + "downloadForced": false, + "id": 1 + }, + { + "artistId": 1, + "albumId": 1, + "quality": { + "quality": { "id": 0, "name": "Unknown" }, + "revision": { "version": 1, "real": 0, "isRepack": false } + }, + "size": 2000000, + "title": "string2", + "sizeleft": 1000000, + "timeleft": "00:00:10", + "estimatedCompletionTime": "2020-09-26T18:47:46Z", + "status": "downloading", + "trackedDownloadStatus": "ok", + "trackedDownloadState": "downloading", + "statusMessages": [], + "downloadId": "string", + "protocol": "string", + "downloadClient": "testclient", + "indexer": "test", + "outputPath": "/downloads/string", + "downloadForced": false, + "id": 1 + } + ] +} diff --git a/tests/components/lidarr/fixtures/rootfolder-linux.json b/tests/components/lidarr/fixtures/rootfolder-linux.json new file mode 100644 index 000000000000..070703279b4d --- /dev/null +++ b/tests/components/lidarr/fixtures/rootfolder-linux.json @@ -0,0 +1,15 @@ +[ + { + "name": "/music/", + "path": "/music/", + "defaultMetadataProfileId": 1, + "defaultQualityProfileId": 1, + "defaultMonitorOption": "all", + "defaultNewItemMonitorOption": "all", + "defaultTags": [], + "accessible": true, + "freeSpace": 1000000000, + "totalSpace": 100000000000, + "id": 2 + } +] diff --git a/tests/components/lidarr/fixtures/system-status.json b/tests/components/lidarr/fixtures/system-status.json index 6baa9428ff63..bd49c5815367 100644 --- a/tests/components/lidarr/fixtures/system-status.json +++ b/tests/components/lidarr/fixtures/system-status.json @@ -5,8 +5,8 @@ "isProduction": false, "isAdmin": false, "isUserInteractive": true, - "startupPath": "C:\\ProgramData\\Radarr", - "appData": "C:\\ProgramData\\Radarr", + "startupPath": "C:\\ProgramData\\Lidarr", + "appData": "C:\\ProgramData\\Lidarr", "osName": "Windows", "osVersion": "10.0.18363.0", "isNetCore": true, diff --git a/tests/components/lidarr/fixtures/wanted-missing.json b/tests/components/lidarr/fixtures/wanted-missing.json new file mode 100644 index 000000000000..2b5886b39dd7 --- /dev/null +++ b/tests/components/lidarr/fixtures/wanted-missing.json @@ -0,0 +1,134 @@ +{ + "page": 1, + "pageSize": 20, + "sortKey": "title", + "sortDirection": "default", + "totalRecords": 1, + "records": [ + { + "title": "test", + "disambiguation": "string", + "overview": "string", + "artistId": 0, + "foreignAlbumId": "string", + "monitored": true, + "anyReleaseOk": true, + "profileId": 1, + "duration": 0, + "albumType": "Album", + "secondaryTypes": [ + { + "id": 0, + "name": "string" + } + ], + "mediumCount": 1, + "ratings": { + "votes": 0, + "value": 0 + }, + "releaseDate": "1968-01-01T00:00:00Z", + "releases": [ + { + "id": 0, + "albumId": 0, + "foreignReleaseId": "string", + "title": "string", + "status": "string", + "duration": 0, + "trackCount": 1, + "media": [ + { + "mediumNumber": 1, + "mediumName": "Unknown", + "mediumFormat": "Unknown" + } + ], + "mediumCount": 1, + "disambiguation": "", + "country": ["string"], + "label": ["test"], + "format": "Unknown", + "monitored": true + } + ], + "genres": ["string"], + "media": [ + { + "mediumNumber": 1, + "mediumName": "Unknown", + "mediumFormat": "Unknown" + } + ], + "artist": { + "artistMetadataId": 0, + "status": "continuing", + "ended": false, + "artistName": "test", + "foreignArtistId": "string", + "tadbId": 0, + "discogsId": 0, + "overview": "string", + "artistType": "Group", + "disambiguation": "", + "links": [ + { + "url": "string", + "name": "string" + } + ], + "images": [ + { + "url": "https://test.jpg", + "coverType": "fanart", + "extension": ".jpg" + } + ], + "path": "string", + "qualityProfileId": 1, + "metadataProfileId": 1, + "monitored": true, + "monitorNewItems": "all", + "genres": ["string"], + "cleanName": "string", + "sortName": "string", + "tags": [0], + "added": "2020-03-18T15:51:22Z", + "ratings": { + "votes": 0, + "value": 0 + }, + "statistics": { + "albumCount": 0, + "trackFileCount": 0, + "trackCount": 0, + "totalTrackCount": 0, + "sizeOnDisk": 0, + "percentOfTracks": 0 + }, + "id": 0 + }, + "images": [ + { + "url": "string", + "coverType": "poster" + } + ], + "links": [ + { + "url": "string", + "name": "string" + } + ], + "statistics": { + "trackFileCount": 0, + "trackCount": 1, + "totalTrackCount": 1, + "sizeOnDisk": 0, + "percentOfTracks": 0 + }, + "grabbed": false, + "id": 1 + } + ] +} diff --git a/tests/components/lidarr/test_config_flow.py b/tests/components/lidarr/test_config_flow.py index b78593243403..d3c4352dc1e3 100644 --- a/tests/components/lidarr/test_config_flow.py +++ b/tests/components/lidarr/test_config_flow.py @@ -1,138 +1,104 @@ """Test Lidarr config flow.""" -from unittest.mock import patch - -from aiopyarr import exceptions - from homeassistant import data_entry_flow from homeassistant.components.lidarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_SOURCE from homeassistant.core import HomeAssistant -from . import API_KEY, CONF_DATA, MOCK_USER_INPUT, create_entry, mock_connection - -from tests.test_util.aiohttp import AiohttpClientMocker +from .conftest import CONF_DATA, MOCK_INPUT, ComponentSetup -def _patch_client(): - return patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_get_system_status" - ) - - -async def test_flow_user_form( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_flow_user_form(hass: HomeAssistant, connection) -> None: """Test that the user set up form is served.""" - mock_connection(aioclient_mock) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, ) - with patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", - return_value=("/api/v3", API_KEY, ""), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=MOCK_USER_INPUT, - ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_INPUT, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == DEFAULT_NAME assert result["data"] == CONF_DATA -async def test_flow_user_invalid_auth(hass: HomeAssistant) -> None: +async def test_flow_user_invalid_auth(hass: HomeAssistant, invalid_auth) -> None: """Test invalid authentication.""" - with _patch_client() as client: - client.side_effect = exceptions.ArrAuthenticationException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=CONF_DATA, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "invalid_auth" -async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: +async def test_flow_user_cannot_connect(hass: HomeAssistant, cannot_connect) -> None: """Test connection error.""" - with _patch_client() as client: - client.side_effect = exceptions.ArrConnectionException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "cannot_connect" -async def test_wrong_app(hass: HomeAssistant) -> None: +async def test_wrong_app(hass: HomeAssistant, wrong_app) -> None: """Test we show user form on wrong app.""" - with patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", - side_effect=exceptions.ArrWrongAppException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data=MOCK_USER_INPUT, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=MOCK_INPUT, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "wrong_app" -async def test_zero_conf_failure(hass: HomeAssistant) -> None: - """Test we show user form on api key retrieval failure.""" - with patch( - "homeassistant.components.lidarr.config_flow.LidarrClient.async_try_zeroconf", - side_effect=exceptions.ArrZeroConfException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - data=MOCK_USER_INPUT, - ) +async def test_zeroconf_failed(hass: HomeAssistant, zeroconf_failed) -> None: + """Test we show user form on zeroconf failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=MOCK_INPUT, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "zeroconf_failed" -async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: +async def test_flow_user_unknown_error(hass: HomeAssistant, unknown) -> None: """Test unknown error.""" - with _patch_client() as client: - client.side_effect = exceptions.ArrException - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=CONF_DATA, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"]["base"] == "unknown" async def test_flow_reauth( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, setup_integration: ComponentSetup, connection ) -> None: """Test reauth.""" - entry = create_entry(hass) - mock_connection(aioclient_mock) + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.flow.async_init( DOMAIN, context={ diff --git a/tests/components/lidarr/test_init.py b/tests/components/lidarr/test_init.py new file mode 100644 index 000000000000..2a217bebd5f8 --- /dev/null +++ b/tests/components/lidarr/test_init.py @@ -0,0 +1,61 @@ +"""Test Lidarr integration.""" +from homeassistant.components.lidarr.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import ComponentSetup + + +async def test_setup( + hass: HomeAssistant, setup_integration: ComponentSetup, connection +) -> None: + """Test setup.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == 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 + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready( + hass: HomeAssistant, setup_integration: ComponentSetup, cannot_connect +) -> None: + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_auth_failed( + hass: HomeAssistant, setup_integration: ComponentSetup, invalid_auth +) -> None: + """Test that it throws ConfigEntryAuthFailed when authentication fails.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_device_info( + hass: HomeAssistant, setup_integration: ComponentSetup, connection +) -> None: + """Test device info.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + device_registry = dr.async_get(hass) + await hass.async_block_till_done() + device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + + assert device.configuration_url == "http://127.0.0.1:8668" + assert device.identifiers == {(DOMAIN, entry.entry_id)} + assert device.manufacturer == DEFAULT_NAME + assert device.name == "Mock Title" + assert device.sw_version == "10.0.0.34882" diff --git a/tests/components/lidarr/test_sensor.py b/tests/components/lidarr/test_sensor.py new file mode 100644 index 000000000000..7fe347f46192 --- /dev/null +++ b/tests/components/lidarr/test_sensor.py @@ -0,0 +1,33 @@ +"""The tests for Lidarr sensor platform.""" +from unittest.mock import AsyncMock + +from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup + + +async def test_sensors( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry_enabled_by_default: AsyncMock, + connection, +): + """Test for successfully setting up the Lidarr platform.""" + await setup_integration() + + state = hass.states.get("sensor.mock_title_disk_space") + assert state.state == "0.93" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB" + state = hass.states.get("sensor.mock_title_queue") + assert state.state == "2" + assert state.attributes.get("string") == "stopped" + assert state.attributes.get("string2") == "downloading" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Albums" + assert state.attributes.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL + state = hass.states.get("sensor.mock_title_wanted") + assert state.state == "1" + assert state.attributes.get("test") == "test" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Albums" + assert state.attributes.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL From f081fa8febbd0f4cd2738077726419a903eb3a29 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Tue, 28 Mar 2023 15:50:59 +0100 Subject: [PATCH 0898/1058] Add basic tests for temper USB temperature sensor integration (#80220) * Add basic tests * Updated requriements_test_all.txt * Update temperusb version * Add type hints Co-authored-by: Christian Knittl-Frank * Add type hints Co-authored-by: Christian Knittl-Frank * Correct typo in type hint * Fix isort * Fix requirements_test_all.txt --------- Co-authored-by: Dave T Co-authored-by: Christian Knittl-Frank --- requirements_test_all.txt | 3 +++ tests/components/temper/__init__.py | 1 + tests/components/temper/test_sensor.py | 35 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/components/temper/__init__.py create mode 100644 tests/components/temper/test_sensor.py diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e94cabd6ac03..7b41d4693614 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1760,6 +1760,9 @@ tellduslive==0.10.11 # homeassistant.components.lg_soundbar temescal==0.5 +# homeassistant.components.temper +temperusb==1.6.0 + # homeassistant.components.powerwall tesla-powerwall==0.3.19 diff --git a/tests/components/temper/__init__.py b/tests/components/temper/__init__.py new file mode 100644 index 000000000000..6ce341cabc95 --- /dev/null +++ b/tests/components/temper/__init__.py @@ -0,0 +1 @@ +"""Tests for the temper integration.""" diff --git a/tests/components/temper/test_sensor.py b/tests/components/temper/test_sensor.py new file mode 100644 index 000000000000..d195ff85c820 --- /dev/null +++ b/tests/components/temper/test_sensor.py @@ -0,0 +1,35 @@ +"""The tests for the temper (USB temperature sensor) component.""" +from datetime import timedelta +from unittest.mock import Mock, patch + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed + + +async def test_temperature_readback(hass: HomeAssistant) -> None: + """Test for reading sensors.""" + mock_temper_device = Mock() + mock_temper_device.get_temperature.return_value = 12.3 + + utcnow = dt_util.utcnow() + + with patch( + "temperusb.temper.TemperHandler.get_devices", + return_value=[mock_temper_device], + ): + await async_setup_component( + hass, + "sensor", + {"sensor": {"platform": "temper", "name": "mydevicename"}}, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, utcnow + timedelta(seconds=70)) + await hass.async_block_till_done() + + temperature = hass.states.get("sensor.mydevicename") + assert temperature + assert temperature.state == "12.3" From 048d30904e62d61fd5d5ab94635e381c94b6fdd4 Mon Sep 17 00:00:00 2001 From: Chris Xiao <30990835+chrisx8@users.noreply.github.com> Date: Tue, 28 Mar 2023 10:52:16 -0400 Subject: [PATCH 0899/1058] Simplify qbittorrent sensor class init (#90411) catch LoginException directly in QBittorrentSensor init Since the `exception` arg in QBittorrentSensor `__init__` is always LoginException, we catch LoginException directly in `__init__` instead of passing LoginException as an argument. --- homeassistant/components/qbittorrent/sensor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index bee7a5d61a67..cafb8d8b21ee 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -90,8 +90,7 @@ def setup_platform( name = config.get(CONF_NAME) entities = [ - QBittorrentSensor(description, client, name, LoginRequired) - for description in SENSOR_TYPES + QBittorrentSensor(description, client, name) for description in SENSOR_TYPES ] add_entities(entities, True) @@ -111,12 +110,10 @@ class QBittorrentSensor(SensorEntity): description: SensorEntityDescription, qbittorrent_client, client_name, - exception, ) -> None: """Initialize the qBittorrent sensor.""" self.entity_description = description self.client = qbittorrent_client - self._exception = exception self._attr_name = f"{client_name} {description.name}" self._attr_available = False @@ -130,7 +127,7 @@ class QBittorrentSensor(SensorEntity): _LOGGER.error("Connection lost") self._attr_available = False return - except self._exception: + except LoginRequired: _LOGGER.error("Invalid authentication") return From e45eab600ff053928a7bf1566984e53ceb31557f Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 28 Mar 2023 09:04:29 -0600 Subject: [PATCH 0900/1058] Add has_value function/test to Jinja2 template (#79550) --- homeassistant/helpers/template.py | 26 ++++++++++++++++++++- tests/helpers/test_template.py | 39 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index f21cfc08f142..d3aa7c81ffbb 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -49,6 +49,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, ) @@ -1470,6 +1471,15 @@ def state_attr(hass: HomeAssistant, entity_id: str, name: str) -> Any: return None +def has_value(hass: HomeAssistant, entity_id: str) -> bool: + """Test if an entity has a valid value.""" + state_obj = _get_state(hass, entity_id) + + return state_obj is not None and ( + state_obj.state not in [STATE_UNAVAILABLE, STATE_UNKNOWN] + ) + + def now(hass: HomeAssistant) -> datetime: """Record fetching now.""" if (render_info := hass.data.get(_RENDER_INFO)) is not None: @@ -2302,6 +2312,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "is_state_attr", "state_attr", "states", + "has_value", "utcnow", "now", "device_attr", @@ -2312,11 +2323,21 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "relative_time", "today_at", ] - hass_filters = ["closest", "expand", "device_id", "area_id", "area_name"] + hass_filters = [ + "closest", + "expand", + "device_id", + "area_id", + "area_name", + "has_value", + ] + hass_tests = ["has_value"] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: self.filters[filt] = unsupported(filt) + for test in hass_tests: + self.filters[test] = unsupported(test) return self.globals["expand"] = hassfunction(expand) @@ -2332,6 +2353,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["state_attr"] = self.globals["state_attr"] self.globals["states"] = AllStates(hass) self.filters["states"] = self.globals["states"] + self.globals["has_value"] = hassfunction(has_value) + self.filters["has_value"] = pass_context(self.globals["has_value"]) + self.tests["has_value"] = pass_eval_context(self.globals["has_value"]) self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) self.globals["relative_time"] = hassfunction(relative_time) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index c9ef9494bf17..45237a5cbf02 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -21,6 +21,7 @@ from homeassistant.const import ( LENGTH_MILLIMETERS, MASS_GRAMS, STATE_ON, + STATE_UNAVAILABLE, TEMP_CELSIUS, VOLUME_LITERS, UnitOfPressure, @@ -1607,6 +1608,44 @@ def test_states_function(hass: HomeAssistant) -> None: assert tpl.async_render() == "available" +def test_has_value(hass): + """Test has_value method.""" + hass.states.async_set("test.value1", 1) + hass.states.async_set("test.unavailable", STATE_UNAVAILABLE) + + tpl = template.Template( + """ +{{ has_value("test.value1") }} + """, + hass, + ) + assert tpl.async_render() is True + + tpl = template.Template( + """ +{{ has_value("test.unavailable") }} + """, + hass, + ) + assert tpl.async_render() is False + + tpl = template.Template( + """ +{{ has_value("test.unknown") }} + """, + hass, + ) + assert tpl.async_render() is False + + tpl = template.Template( + """ +{% if "test.value1" is has_value %}yes{% else %}no{% endif %} + """, + hass, + ) + assert tpl.async_render() == "yes" + + @patch( "homeassistant.helpers.template.TemplateEnvironment.is_safe_callable", return_value=True, From 478a1d5e9a15334cba10a51eb7be50a95aa66f4c Mon Sep 17 00:00:00 2001 From: Wesley Vos <17592840+Wesley-Vos@users.noreply.github.com> Date: Tue, 28 Mar 2023 17:09:20 +0200 Subject: [PATCH 0901/1058] Add periodically resetting meter option to utility meter (#88446) * Use last valid state if meter is not periodically resetting * Fix unload of entry, used during options flow submit * Adjustments based on code review * Move DecimalException handling to validation method * Add test for invalid new state in calculate_adjustment method --- .../components/utility_meter/__init__.py | 26 +- .../components/utility_meter/config_flow.py | 10 + .../components/utility_meter/const.py | 1 + .../components/utility_meter/sensor.py | 133 +++++--- .../components/utility_meter/strings.json | 8 +- .../utility_meter/test_config_flow.py | 61 +++- tests/components/utility_meter/test_init.py | 3 + tests/components/utility_meter/test_sensor.py | 317 +++++++++++++++++- 8 files changed, 510 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c436ea757ac7..11e58fca775c 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -83,6 +84,7 @@ METER_CONFIG_SCHEMA = vol.Schema( ), vol.Optional(CONF_METER_DELTA_VALUES, default=False): cv.boolean, vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, + vol.Optional(CONF_METER_PERIODICALLY_RESETTING, default=True): cv.boolean, vol.Optional(CONF_TARIFFS, default=[]): vol.All( cv.ensure_list, vol.Unique(), [cv.string] ), @@ -221,13 +223,29 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + platforms_to_unload = [Platform.SENSOR] + if entry.options.get(CONF_TARIFFS): + platforms_to_unload.append(Platform.SELECT) + if unload_ok := await hass.config_entries.async_unload_platforms( entry, - ( - Platform.SELECT, - Platform.SENSOR, - ), + platforms_to_unload, ): hass.data[DATA_UTILITY].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + new = {**config_entry.options} + new[CONF_METER_PERIODICALLY_RESETTING] = True + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, options=new) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index c1f82e902d2c..eb5c19941dc9 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -21,6 +21,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFFS, @@ -64,6 +65,9 @@ OPTIONS_SCHEMA = vol.Schema( vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig(domain=SENSOR_DOMAIN), ), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + ): selector.BooleanSelector(), } ) @@ -95,6 +99,10 @@ CONFIG_SCHEMA = vol.Schema( vol.Required( CONF_METER_DELTA_VALUES, default=False ): selector.BooleanSelector(), + vol.Required( + CONF_METER_PERIODICALLY_RESETTING, + default=True, + ): selector.BooleanSelector(), } ) @@ -110,6 +118,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Utility Meter.""" + VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 9b85e9e3ae96..f8a4c2d4b75d 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -32,6 +32,7 @@ CONF_METER_TYPE = "cycle" CONF_METER_OFFSET = "offset" CONF_METER_DELTA_VALUES = "delta_values" CONF_METER_NET_CONSUMPTION = "net_consumption" +CONF_METER_PERIODICALLY_RESETTING = "periodically_resetting" CONF_PAUSED = "paused" CONF_TARIFFS = "tariffs" CONF_TARIFF = "tariff" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 066a3cd6e104..dad2d8dfaf34 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfEnergy, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import entity_platform, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -50,6 +50,7 @@ from .const import ( CONF_METER_DELTA_VALUES, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, + CONF_METER_PERIODICALLY_RESETTING, CONF_METER_TYPE, CONF_SOURCE_SENSOR, CONF_TARIFF, @@ -85,6 +86,7 @@ ATTR_SOURCE_ID = "source" ATTR_STATUS = "status" ATTR_PERIOD = "meter_period" ATTR_LAST_PERIOD = "last_period" +ATTR_LAST_VALID_STATE = "last_valid_state" ATTR_TARIFF = "tariff" DEVICE_CLASS_MAP = { @@ -127,6 +129,7 @@ async def async_setup_entry( meter_type = None name = config_entry.title 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] meters = [] @@ -142,6 +145,7 @@ async def async_setup_entry( name=name, net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=None, @@ -160,6 +164,7 @@ async def async_setup_entry( name=f"{name} {tariff}", net_consumption=net_consumption, parent_meter=entry_id, + periodically_resetting=periodically_resetting, source_entity=source_entity_id, tariff_entity=tariff_entity, tariff=tariff, @@ -223,6 +228,9 @@ async def async_setup_platform( conf_meter_net_consumption = hass.data[DATA_UTILITY][meter][ CONF_METER_NET_CONSUMPTION ] + conf_meter_periodically_resetting = hass.data[DATA_UTILITY][meter][ + CONF_METER_PERIODICALLY_RESETTING + ] conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get( CONF_TARIFF_ENTITY ) @@ -235,6 +243,7 @@ async def async_setup_platform( name=conf_sensor_name, net_consumption=conf_meter_net_consumption, parent_meter=meter, + periodically_resetting=conf_meter_periodically_resetting, source_entity=conf_meter_source, tariff_entity=conf_meter_tariff_entity, tariff=conf_sensor_tariff, @@ -262,6 +271,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): last_period: Decimal last_reset: datetime | None + last_valid_state: Decimal | None status: str def as_dict(self) -> dict[str, Any]: @@ -270,6 +280,9 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): data["last_period"] = str(self.last_period) if isinstance(self.last_reset, (datetime)): data["last_reset"] = self.last_reset.isoformat() + data["last_valid_state"] = ( + str(self.last_valid_state) if self.last_valid_state else None + ) data["status"] = self.status return data @@ -284,6 +297,11 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): try: last_period: Decimal = Decimal(restored["last_period"]) last_reset: datetime | None = dt_util.parse_datetime(restored["last_reset"]) + last_valid_state: Decimal | None = ( + Decimal(restored["last_valid_state"]) + if restored.get("last_valid_state") + else None + ) status: str = restored["status"] except KeyError: # restored is a dict, but does not have all values @@ -297,6 +315,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): extra.native_unit_of_measurement, last_period, last_reset, + last_valid_state, status, ) @@ -316,6 +335,7 @@ class UtilityMeterSensor(RestoreSensor): name, net_consumption, parent_meter, + periodically_resetting, source_entity, tariff_entity, tariff, @@ -330,6 +350,7 @@ class UtilityMeterSensor(RestoreSensor): self._state = None self._last_period = Decimal(0) self._last_reset = dt_util.utcnow() + self._last_valid_state = None self._collecting = None self._name = name self._unit_of_measurement = None @@ -346,6 +367,7 @@ class UtilityMeterSensor(RestoreSensor): self._cron_pattern = cron_pattern self._sensor_delta_values = delta_values self._sensor_net_consumption = net_consumption + self._sensor_periodically_resetting = periodically_resetting self._tariff = tariff self._tariff_entity = tariff_entity @@ -355,53 +377,70 @@ class UtilityMeterSensor(RestoreSensor): self._state = 0 self.async_write_ha_state() - @callback - def async_reading(self, event): - """Handle the sensor state changes.""" - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + @staticmethod + def _validate_state(state: State | None) -> Decimal | None: + """Parse the state as a Decimal if available. Throws DecimalException if the state is not a number.""" + try: + return ( + None + if state is None or state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN] + else Decimal(state.state) + ) + except DecimalException: + return None - if self._state is None and new_state.state: + def calculate_adjustment( + self, old_state: State | None, new_state: State + ) -> Decimal | None: + """Calculate the adjustment based on the old and new state.""" + + # First check if the new_state is valid (see discussion in PR #88446) + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", new_state.state) + return None + + if self._sensor_delta_values: + return new_state_val + + if ( + not self._sensor_periodically_resetting + and self._last_valid_state is not None + ): # Fallback to old_state if sensor is periodically resetting but last_valid_state is None + return new_state_val - self._last_valid_state + + if (old_state_val := self._validate_state(old_state)) is not None: + return new_state_val - old_state_val + _LOGGER.warning( + "Invalid state (%s > %s)", + old_state.state if old_state else None, + new_state_val, + ) + return None + + @callback + def async_reading(self, event: Event): + """Handle the sensor state changes.""" + old_state: State | None = event.data.get("old_state") + new_state: State = event.data.get("new_state") # type: ignore[assignment] # a state change event always has a new state + + if (new_state_val := self._validate_state(new_state)) is None: + _LOGGER.warning("Invalid state %s", new_state.state) + return + + if self._state is None: # First state update initializes the utility_meter sensors - source_state = self.hass.states.get(self._sensor_source_id) for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][ DATA_TARIFF_SENSORS ]: - sensor.start(source_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + sensor.start(new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) if ( - new_state is None - or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - or ( - not self._sensor_delta_values - and ( - old_state is None - or old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] - ) - ) - ): - return + adjustment := self.calculate_adjustment(old_state, new_state) + ) is not None and (self._sensor_net_consumption or adjustment >= 0): + # If net_consumption is off, the adjustment must be non-negative + self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line - self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - - try: - if self._sensor_delta_values: - adjustment = Decimal(new_state.state) - else: - adjustment = Decimal(new_state.state) - Decimal(old_state.state) - - if (not self._sensor_net_consumption) and adjustment < 0: - # Source sensor just rolled over for unknown reasons, - return - self._state += adjustment - - except DecimalException as err: - if self._sensor_delta_values: - _LOGGER.warning("Invalid adjustment of %s: %s", new_state.state, err) - else: - _LOGGER.warning( - "Invalid state (%s > %s): %s", old_state.state, new_state.state, err - ) + self._last_valid_state = new_state_val self.async_write_ha_state() @callback @@ -422,6 +461,11 @@ class UtilityMeterSensor(RestoreSensor): self._collecting() self._collecting = None + # Reset the last_valid_state during state change because if the last state before the tariff change was invalid, + # there is no way to know how much "adjustment" counts for which tariff. Therefore, we set the last_valid_state + # to None and let the fallback mechanism handle the case that the old state was valid + self._last_valid_state = None + _LOGGER.debug( "%s - %s - source <%s>", self._name, @@ -484,6 +528,7 @@ class UtilityMeterSensor(RestoreSensor): self._unit_of_measurement = last_sensor_data.native_unit_of_measurement self._last_period = last_sensor_data.last_period self._last_reset = last_sensor_data.last_reset + self._last_valid_state = last_sensor_data.last_valid_state if last_sensor_data.status == COLLECTING: # Null lambda to allow cancelling the collection on tariff change self._collecting = lambda: None @@ -508,6 +553,12 @@ class UtilityMeterSensor(RestoreSensor): and is_number(state.attributes[ATTR_LAST_PERIOD]) else Decimal(0) ) + self._last_valid_state = ( + Decimal(state.attributes[ATTR_LAST_VALID_STATE]) + if state.attributes.get(ATTR_LAST_VALID_STATE) + and is_number(state.attributes[ATTR_LAST_VALID_STATE]) + else None + ) self._last_reset = dt_util.as_utc( dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) ) @@ -590,6 +641,7 @@ class UtilityMeterSensor(RestoreSensor): ATTR_SOURCE_ID: self._sensor_source_id, ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING, ATTR_LAST_PERIOD: str(self._last_period), + ATTR_LAST_VALID_STATE: str(self._last_valid_state), } if self._period is not None: state_attr[ATTR_PERIOD] = self._period @@ -620,6 +672,7 @@ class UtilityMeterSensor(RestoreSensor): self.native_unit_of_measurement, self._last_period, self._last_reset, + self._last_valid_state, PAUSED if self._collecting is None else COLLECTING, ) diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index e9f8e7f25053..1eeacbae8003 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -9,6 +9,7 @@ "cycle": "Meter reset cycle", "delta_values": "Delta values", "name": "Name", + "periodically_resetting": "Periodically resetting", "net_consumption": "Net consumption", "offset": "Meter reset offset", "source": "Input sensor", @@ -17,6 +18,7 @@ "data_description": { "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.", "offset": "Offset the day of a monthly meter reset.", "tariffs": "A list of supported tariffs, leave empty if only a single tariff is needed." } @@ -27,7 +29,11 @@ "step": { "init": { "data": { - "source": "[%key:component::utility_meter::config::step::user::data::source%]" + "source": "[%key:component::utility_meter::config::step::user::data::source%]", + "periodically_resetting": "[%key:component::utility_meter::config::step::user::data::periodically_resetting%]" + }, + "data_description": { + "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 8deb7601aa64..302d3879a04b 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -47,6 +47,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -60,6 +61,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": [], } @@ -96,6 +98,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "delta_values": False, "name": "Electricity meter", "net_consumption": False, + "periodically_resetting": True, "offset": 0, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], @@ -109,6 +112,7 @@ async def test_tariffs(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": ["cat", "dog", "horse", "cow"], } @@ -136,6 +140,57 @@ async def test_tariffs(hass: HomeAssistant) -> None: assert result["errors"]["base"] == "tariffs_not_unique" +async def test_non_periodically_resetting(hass: HomeAssistant) -> None: + """Test periodically resetting.""" + 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": [], + }, + ) + 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, + "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, + "source": input_sensor_entity_id, + "tariffs": [], + } + + def get_suggested(schema, key): """Get suggested value for key in voluptuous schema.""" for k in schema: @@ -162,6 +217,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor1_entity_id, "tariffs": "", }, @@ -176,10 +232,11 @@ async def test_options(hass: HomeAssistant) -> None: assert result["step_id"] == "init" schema = result["data_schema"].schema assert get_suggested(schema, "source") == input_sensor1_entity_id + assert get_suggested(schema, "periodically_resetting") is True result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"source": input_sensor2_entity_id}, + user_input={"source": input_sensor2_entity_id, "periodically_resetting": False}, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == { @@ -188,6 +245,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": False, "source": input_sensor2_entity_id, "tariffs": "", } @@ -198,6 +256,7 @@ async def test_options(hass: HomeAssistant) -> None: "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": False, "source": input_sensor2_entity_id, "tariffs": "", } diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index ad4fc5e6e9ac..5c8d8d4253ce 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -186,6 +186,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["peak", "offpeak"], }, @@ -202,6 +203,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: "name": "Energy bill2", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["peak", "offpeak"], }, @@ -413,6 +415,7 @@ async def test_setup_and_remove_config_entry( "name": "Electricity meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": input_sensor_entity_id, "tariffs": tariffs, }, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index c56010e36e55..d84099b4d668 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) +from homeassistant.components.utility_meter import DEFAULT_OFFSET from homeassistant.components.utility_meter.const import ( ATTR_VALUE, DAILY, @@ -24,9 +25,11 @@ from homeassistant.components.utility_meter.const import ( ) from homeassistant.components.utility_meter.sensor import ( ATTR_LAST_RESET, + ATTR_LAST_VALID_STATE, ATTR_STATUS, COLLECTING, PAUSED, + UtilityMeterSensor, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -50,7 +53,7 @@ from tests.common import ( @pytest.fixture(autouse=True) -def set_utc(hass): +def set_utc(hass: HomeAssistant): """Set timezone to UTC.""" hass.config.set_time_zone("UTC") @@ -77,6 +80,7 @@ def set_utc(hass): "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak"], }, @@ -272,6 +276,7 @@ async def test_not_unique_tariffs(hass: HomeAssistant, yaml_config) -> None: "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak"], }, @@ -430,6 +435,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) -> "name": "Energy meter", "net_consumption": True, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -439,6 +445,7 @@ async def test_entity_name(hass: HomeAssistant, yaml_config, entity_id, name) -> "name": "Gas meter", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.gas", "tariffs": [], }, @@ -516,6 +523,7 @@ async def test_device_class( "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": ["onpeak", "midpeak", "offpeak", "superpeak"], }, @@ -552,6 +560,7 @@ async def test_restore_state( "native_unit_of_measurement": "kWh", "last_reset": last_reset, "last_period": "7", + "last_valid_state": "None", "status": "paused", }, ), @@ -562,6 +571,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: PAUSED, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -571,6 +581,7 @@ async def test_restore_state( "decimal_str": "3", }, "native_unit_of_measurement": "kWh", + "last_valid_state": "None", }, ), ( @@ -580,6 +591,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -589,6 +601,7 @@ async def test_restore_state( "decimal_str": "3f", }, "native_unit_of_measurement": "kWh", + "last_valid_state": "None", }, ), ( @@ -598,6 +611,7 @@ async def test_restore_state( attributes={ ATTR_STATUS: COLLECTING, ATTR_LAST_RESET: last_reset, + ATTR_LAST_VALID_STATE: None, ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, }, ), @@ -625,15 +639,18 @@ async def test_restore_state( assert state.state == "3" assert state.attributes.get("status") == PAUSED assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_valid_state") == "None" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_midpeak") assert state.state == "5" + assert state.attributes.get("last_valid_state") == "None" state = hass.states.get("sensor.energy_bill_offpeak") assert state.state == "6" assert state.attributes.get("status") == COLLECTING assert state.attributes.get("last_reset") == last_reset + assert state.attributes.get("last_valid_state") == "None" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR state = hass.states.get("sensor.energy_bill_superpeak") @@ -675,6 +692,7 @@ async def test_restore_state( "name": "Energy bill", "net_consumption": True, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -829,6 +847,7 @@ async def test_non_net_consumption( "name": "Energy bill", "net_consumption": False, "offset": 0, + "periodically_resetting": True, "source": "sensor.energy", "tariffs": [], }, @@ -884,7 +903,7 @@ async def test_delta_values( force_update=True, ) await hass.async_block_till_done() - assert "Invalid adjustment of None" in caplog.text + assert "Invalid state None" in caplog.text now += timedelta(seconds=30) with freeze_time(now): @@ -918,6 +937,272 @@ async def test_delta_values( assert state.state == "9" +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "periodically_resetting": False, + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": "sensor.energy", + "tariffs": [], + }, + ), + ), +) +async def test_non_periodically_resetting( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test utility meter "non periodically resetting" mode.""" + # Home assistant is not runnit yet + hass.state = CoreState.not_running + + now = dt_util.utcnow() + with freeze_time(now): + 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"], + version=2, + ) + 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_START) + + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, 1, {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR} + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state.attributes.get("status") == PAUSED + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, 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.state == "2" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + {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.state == "2" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + hass.states.async_set( + entity_id, + 6, + {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.state == "5" + assert state.attributes.get("last_valid_state") == "6" + assert state.attributes.get("status") == COLLECTING + + now += timedelta(seconds=30) + with freeze_time(now): + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + hass.states.async_set( + entity_id, + 9, + {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.state == "8" + assert state.attributes.get("last_valid_state") == "9" + assert state.attributes.get("status") == COLLECTING + + +@pytest.mark.parametrize( + ("yaml_config", "config_entry_config"), + ( + ( + { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "periodically_resetting": False, + "tariffs": ["low", "high"], + } + } + }, + None, + ), + ( + None, + { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": False, + "source": "sensor.energy", + "tariffs": ["low", "high"], + }, + ), + ), +) +async def test_non_periodically_resetting_meter_with_tariffs( + hass: HomeAssistant, yaml_config, config_entry_config +) -> None: + """Test test_non_periodically_resetting_meter_with_tariffs.""" + 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"], + version=2, + ) + 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_START) + 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_low") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == COLLECTING + assert state.attributes.get("last_valid_state") == "2" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "0" + assert state.attributes.get("status") == PAUSED + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + + now = dt_util.utcnow() + timedelta(seconds=10) + with patch("homeassistant.util.dt.utcnow", return_value=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_low") + assert state is not None + assert state.state == "1" + assert state.attributes.get("last_valid_state") == "3" + assert state.attributes.get("status") == COLLECTING + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "0" + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.energy_bill", "option": "high"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + state = hass.states.get("sensor.energy_bill_high") + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == COLLECTING + + now = dt_util.utcnow() + timedelta(seconds=20) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 6, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill_low") + assert state is not None + assert state.state == "1" + assert state.attributes.get("last_valid_state") == "None" + assert state.attributes.get("status") == PAUSED + + state = hass.states.get("sensor.energy_bill_high") + assert state is not None + assert state.state == "3" + assert state.attributes.get("last_valid_state") == "6" + assert state.attributes.get("status") == COLLECTING + + def gen_config(cycle, offset=None): """Generate configuration.""" config = { @@ -932,7 +1217,9 @@ def gen_config(cycle, offset=None): return config -async def _test_self_reset(hass, config, start_time, expect_reset=True): +async def _test_self_reset( + hass: HomeAssistant, config, start_time, expect_reset=True +) -> None: """Test energy sensor self reset.""" now = dt_util.parse_datetime(start_time) with freeze_time(now): @@ -1142,3 +1429,27 @@ async def test_bad_offset(hass: HomeAssistant) -> None: assert not await async_setup_component( hass, DOMAIN, gen_config("monthly", timedelta(days=31)) ) + + +def test_calculate_adjustment_invalid_new_state( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that calculate_adjustment method returns None if the new state is invalid.""" + mock_sensor = UtilityMeterSensor( + cron_pattern=None, + delta_values=False, + meter_offset=DEFAULT_OFFSET, + meter_type=DAILY, + name="Test utility meter", + net_consumption=False, + parent_meter="sensor.test", + periodically_resetting=True, + unique_id="test_utility_meter", + source_entity="sensor.test", + tariff=None, + tariff_entity=None, + ) + + new_state: State = State(entity_id="sensor.test", state="unknown") + assert mock_sensor.calculate_adjustment(None, new_state) is None + assert "Invalid state unknown" in caplog.text From d907bd2ca3980f9f065273c0591a925e6b22c393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 28 Mar 2023 17:09:59 +0200 Subject: [PATCH 0902/1058] Add connected relayer region to system health (#90410) --- homeassistant/components/cloud/client.py | 11 +++++++++++ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/strings.json | 1 + .../components/cloud/system_health.py | 1 + homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_client.py | 19 +++++++++++++++++++ tests/components/cloud/test_system_health.py | 4 +++- 9 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 08d43644249b..900779f6b019 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -47,6 +47,7 @@ class CloudClient(Interface): self._google_config: google_config.CloudGoogleConfig | None = None self._alexa_config_init_lock = asyncio.Lock() self._google_config_init_lock = asyncio.Lock() + self._relayer_region: str | None = None @property def base_path(self) -> Path: @@ -84,6 +85,11 @@ class CloudClient(Interface): """Return true if we want start a remote connection.""" return self._prefs.remote_enabled + @property + def relayer_region(self) -> str | None: + """Return the connected relayer region.""" + return self._relayer_region + async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig: """Return Alexa config.""" if self._alexa_config is None: @@ -256,6 +262,11 @@ class CloudClient(Interface): "headers": {"Content-Type": response.content_type}, } + async def async_system_message(self, payload: dict[Any, Any] | None) -> None: + """Handle system messages.""" + if payload and (region := payload.get("region")): + self._relayer_region = region + async def async_cloudhooks_update(self, data: dict[str, dict[str, str]]) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7bd4a822fba4..2bff4003669a 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.62.0"] + "requirements": ["hass-nabucasa==0.63.1"] } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index e437fca9ed35..432a4db0f772 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -5,6 +5,7 @@ "can_reach_cloud": "Reach Home Assistant Cloud", "can_reach_cloud_auth": "Reach Authentication Server", "relayer_connected": "Relayer Connected", + "relayer_region": "Relayer Region", "remote_connected": "Remote Connected", "remote_enabled": "Remote Enabled", "remote_server": "Remote Server", diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 9f836114b3e0..b1f1774aa479 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -28,6 +28,7 @@ async def system_health_info(hass): if cloud.is_logged_in: data["subscription_expiration"] = cloud.expiration_date data["relayer_connected"] = cloud.is_connected + data["relayer_region"] = client.relayer_region data["remote_enabled"] = client.prefs.remote_enabled data["remote_connected"] = cloud.remote.is_connected data["alexa_enabled"] = client.prefs.alexa_enabled diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 518bea69fbeb..dea105f29674 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ cryptography==40.0.1 dbus-fast==1.84.2 fnvhash==0.1.0 ha-av==10.0.0 -hass-nabucasa==0.62.0 +hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 diff --git a/requirements_all.txt b/requirements_all.txt index 46599fbafe7a..27a50ca564f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -868,7 +868,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.62.0 +hass-nabucasa==0.63.1 # homeassistant.components.splunk hass_splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b41d4693614..f4a9437f9b33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -666,7 +666,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.62.0 +hass-nabucasa==0.63.1 # homeassistant.components.conversation hassil==1.0.6 diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 9f463803b821..b7bfed53aacb 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -312,3 +312,22 @@ async def test_login_recovers_bad_internet( await hass.async_block_till_done() assert len(client._alexa_config.async_enable_proactive_mode.mock_calls) == 2 + + +async def test_system_msg(hass: HomeAssistant) -> None: + """Test system msg.""" + with patch("hass_nabucasa.Cloud.initialize"): + setup = await async_setup_component(hass, "cloud", {"cloud": {}}) + assert setup + cloud = hass.data["cloud"] + + assert cloud.client.relayer_region is None + + response = await cloud.client.async_system_message( + { + "region": "xx-earth-616", + } + ) + + assert response is None + assert cloud.client.relayer_region == "xx-earth-616" diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index b2b74d892995..96b87936da4b 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -36,11 +36,12 @@ async def test_cloud_system_health( expiration_date=now, is_connected=True, client=Mock( + relayer_region="xx-earth-616", prefs=Mock( remote_enabled=True, alexa_enabled=True, google_enabled=False, - ) + ), ), ) @@ -54,6 +55,7 @@ async def test_cloud_system_health( "logged_in": True, "subscription_expiration": now, "relayer_connected": True, + "relayer_region": "xx-earth-616", "remote_enabled": True, "remote_connected": False, "remote_server": "us-west-1", From 0a51914740c8fc4810f8cd4625c62e3d01b1d548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Huryn?= Date: Tue, 28 Mar 2023 17:11:48 +0200 Subject: [PATCH 0903/1058] Blebox cover tilt (#85515) * feature: added tilt for shutterBox * test: include tilt in tests --- homeassistant/components/blebox/cover.py | 17 ++++++++ tests/components/blebox/test_cover.py | 49 ++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 80e2fbd30e7b..658a9bc30cca 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -8,6 +8,7 @@ import blebox_uniapi.cover from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -67,6 +68,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): self._attr_supported_features = ( position | stop | CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) + if feature.has_tilt: + self._attr_supported_features = ( + self._attr_supported_features | CoverEntityFeature.SET_TILT_POSITION + ) @property def current_cover_position(self) -> int | None: @@ -77,6 +82,12 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): return None if position is None else 100 - position + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt of shutter.""" + position = self._feature.tilt_current + return None if position is None else 100 - position + @property def is_opening(self) -> bool | None: """Return whether cover is opening.""" @@ -110,6 +121,12 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): """Stop the cover.""" await self._feature.async_stop() + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Set the tilt position.""" + + position = kwargs[ATTR_TILT_POSITION] + await self._feature.async_set_tilt_position(100 - position) + def _is_state(self, state_name) -> bool | None: value = BLEBOX_TO_HASS_COVER_STATES[self._feature.state] return None if value is None else value == state_name diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index ce7006951fb2..d0a10cb5bded 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -7,7 +7,9 @@ import pytest from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -21,6 +23,7 @@ from homeassistant.const import ( SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_UNKNOWN, ) @@ -43,8 +46,10 @@ def shutterbox_fixture(): full_name="shutterBox-position", device_class="shutter", current=None, + tilt_current=None, state=None, has_stop=True, + has_tilt=True, is_slider=True, ) product = feature.product @@ -420,3 +425,47 @@ async def test_closed_state(feature, hass: HomeAssistant) -> None: feature_mock.async_update = AsyncMock(side_effect=initial_update) await async_setup_entity(hass, entity_id) assert hass.states.get(entity_id).state == STATE_CLOSED + + +async def test_tilt_position(shutterbox, hass): + """Test tilt capability is available.""" + + feature_mock, entity_id = shutterbox + + def tilt_update(): + feature_mock.tilt_current = 90 + + feature_mock.async_update = AsyncMock(side_effect=tilt_update) + + await async_setup_entity(hass, entity_id) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 + + +async def test_set_tilt_position(shutterbox, hass): + """Test tilt position setting.""" + + feature_mock, entity_id = shutterbox + + def initial_update(): + feature_mock.state = 3 + + def set_tilt(tilt_position): + assert tilt_position == 20 + feature_mock.state = 1 + + feature_mock.async_update = AsyncMock(side_effect=initial_update) + feature_mock.async_set_tilt_position = AsyncMock(side_effect=set_tilt) + + await async_setup_entity(hass, entity_id) + assert hass.states.get(entity_id).state == STATE_CLOSED + + feature_mock.async_update = AsyncMock() + await hass.services.async_call( + "cover", + SERVICE_SET_COVER_TILT_POSITION, + {"entity_id": entity_id, ATTR_TILT_POSITION: 80}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_OPENING From 9fecdddf0114079676c45d1e4c593473ee11e016 Mon Sep 17 00:00:00 2001 From: DerEnderKeks Date: Tue, 28 Mar 2023 17:14:52 +0200 Subject: [PATCH 0904/1058] Don't use force_update for Tasmota sensors (#85943) * fix: don't use force_update for Tasmota sensors * Update binary_sensor.py * Update test_binary_sensor.py * Update test_sensor.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/tasmota/binary_sensor.py | 8 ++++---- homeassistant/components/tasmota/sensor.py | 1 - tests/components/tasmota/test_binary_sensor.py | 2 +- tests/components/tasmota/test_sensor.py | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 2bc23655a20b..d84087b31325 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -58,17 +58,17 @@ class TasmotaBinarySensor( ): """Representation a Tasmota binary sensor.""" - _attr_force_update = True + _delay_listener: Callable | None = None + _on_off_state: bool | None = None _tasmota_entity: tasmota_switch.TasmotaSwitch def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota binary sensor.""" - self._delay_listener: Callable | None = None - self._on_off_state: bool | None = None - super().__init__( **kwds, ) + if self._tasmota_entity.off_delay is not None: + self._attr_force_update = True async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 61c03b707cfa..ddcdb3e8c26e 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -268,7 +268,6 @@ async def async_setup_entry( class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): """Representation of a Tasmota sensor.""" - _attr_force_update = True _tasmota_entity: tasmota_sensor.TasmotaSensor def __init__(self, **kwds: Any) -> None: diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py index 8b3607bb9f08..6a82a0f0e736 100644 --- a/tests/components/tasmota/test_binary_sensor.py +++ b/tests/components/tasmota/test_binary_sensor.py @@ -106,7 +106,7 @@ async def test_controlling_state_via_mqtt( entity = hass.data["entity_components"]["binary_sensor"].get_entity( "binary_sensor.tasmota_binary_sensor_1" ) - assert entity.force_update + assert not entity.force_update async def test_controlling_state_via_mqtt_switchname( diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 3a715ea95e64..7eee8fcbe7cb 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -545,7 +545,7 @@ async def test_status_sensor_state_via_mqtt( entity = hass.data["entity_components"]["sensor"].get_entity( "sensor.tasmota_status" ) - assert entity.force_update + assert not entity.force_update @pytest.mark.parametrize("status_sensor_disabled", [False]) From 4b3c1f2800ee18e3c3da1fff1e138daf8c79abd0 Mon Sep 17 00:00:00 2001 From: Dmitry Vlasov Date: Tue, 28 Mar 2023 18:24:19 +0300 Subject: [PATCH 0905/1058] Update zwave-me-ws version to 0.3.6 (#90233) --- homeassistant/components/zwave_me/__init__.py | 35 ++++++++++++++++++- .../components/zwave_me/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 346831b34d9b..1740820d0ba5 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -50,6 +50,8 @@ class ZWaveMeController: self.zwave_api = ZWaveMe( on_device_create=self.on_device_create, on_device_update=self.on_device_update, + on_device_remove=self.on_device_unavailable, + on_device_destroy=self.on_device_destroy, on_new_device=self.add_device, token=self.config.data[CONF_TOKEN], url=self.config.data[CONF_URL], @@ -82,6 +84,14 @@ class ZWaveMeController: """Send signal to update device.""" dispatcher_send(self._hass, f"ZWAVE_ME_INFO_{new_info.id}", new_info) + def on_device_unavailable(self, device_id: str) -> None: + """Send signal to set device unavailable.""" + dispatcher_send(self._hass, f"ZWAVE_ME_UNAVAILABLE_{device_id}") + + def on_device_destroy(self, device_id: str) -> None: + """Send signal to destroy device.""" + dispatcher_send(self._hass, f"ZWAVE_ME_DESTROY_{device_id}") + def remove_stale_devices(self, registry: dr.DeviceRegistry): """Remove old-format devices in the registry.""" for device_id in self.device_ids: @@ -133,10 +143,33 @@ class ZWaveMeEntity(Entity): self.hass, f"ZWAVE_ME_INFO_{self.device.id}", self.get_new_data ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"ZWAVE_ME_UNAVAILABLE_{self.device.id}", + self.set_unavailable_status, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"ZWAVE_ME_DESTROY_{self.device.id}", self.delete_entity + ) + ) @callback - def get_new_data(self, new_data): + def get_new_data(self, new_data: ZWaveMeData) -> None: """Update info in the HAss.""" self.device = new_data self._attr_available = not new_data.isFailed self.async_write_ha_state() + + @callback + def set_unavailable_status(self): + """Update status in the HAss.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def delete_entity(self) -> None: + """Remove this entity.""" + self.hass.async_create_task(self.async_remove(force_remove=True)) diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 633901596816..388a8c2c1d48 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave_me_ws==0.3.1", "url-normalize==1.4.3"], + "requirements": ["zwave_me_ws==0.3.6", "url-normalize==1.4.3"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 27a50ca564f4..4c5dec6d8c6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2731,4 +2731,4 @@ zm-py==0.5.2 zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me -zwave_me_ws==0.3.1 +zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f4a9437f9b33..5ab64c066cab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1956,4 +1956,4 @@ zigpy==0.53.2 zwave-js-server-python==0.47.0 # homeassistant.components.zwave_me -zwave_me_ws==0.3.1 +zwave_me_ws==0.3.6 From 89a3c304c2b9216510600ed66e2a064fb161e76d Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 28 Mar 2023 18:39:10 +0200 Subject: [PATCH 0906/1058] Refactor ZHA binary sensors to read from zigpy cache (#89481) * Construct binary sensor state from zigpy cache (WIP) * Workaround zha-quirks issue where "MotionWithReset" quirks don't update attribute cache (WIP) zha-quirks currently has an issue where the ZONE_STATE attribute is updated (when the zone_STATUS changes). https://github.com/zigpy/zha-device-handlers/pull/2231 is a proper fix for this. For now, we just update the attribute cache when we get the "zone status update notification" command. This wasn't noticed before, as the "attribute report signal" was sent from the `cluster_command()` method and the used the provided attribute (in the signal) to update the `_state` value in the binary sensor class. As we just tell HA to write state again when we get an attribute report now, the ZONE_STATUS attribute is read now (and needs to be correct). * Use parse() method of main class for IasZone entity (with stripped bits) * Change wording in comment, remove explicitly sending attr signal (This comment should be removed/changed later anyway) * Remove note * Get zone_status attribute id with zigpy * Remove `security.` prefix for `IasZone` import `AceCluster` was already directly imported and `IasZone` is too now for getting the attribute id * Store full zone status attribute in cache * Check that non-alarm bits are ignored in IasZone sensor test * Re-enable occupancy binary sensor test This test seems to work fine and I don't see any reason why it was commented out for a while * Fix cached read mix-up for `zone_status`/`zone_state` This allows cached reads for `zone_state` (enrolled or not), but forces a new read for `zone_status` (alarm or not). --- homeassistant/components/zha/binary_sensor.py | 37 ++++++------------- .../components/zha/core/channels/security.py | 20 +++++----- tests/components/zha/test_binary_sensor.py | 7 +++- 3 files changed, 28 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b6a0af8e4597..9c2fb49de61a 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -76,34 +76,23 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - super().async_restore_last_state(last_state) - self._state = last_state.state == STATE_ON - @property def is_on(self) -> bool: """Return True if the switch is on based on the state machine.""" - if self._state is None: + raw_state = self._channel.cluster.get(self.SENSOR_ATTR) + if raw_state is None: return False - return self._state + return self.parse(raw_state) @callback def async_set_state(self, attr_id, attr_name, value): """Set the state.""" - if self.SENSOR_ATTR is None or attr_name != self.SENSOR_ATTR: - return - self._state = bool(value) self.async_write_ha_state() - async def async_update(self) -> None: - """Attempt to retrieve on off state from the binary sensor.""" - await super().async_update() - attribute = getattr(self._channel, "value_attribute", "on_off") - attr_value = await self._channel.get_attribute_value(attribute) - if attr_value is not None: - self._state = attr_value + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return bool(value) @MULTI_MATCH(channel_names=CHANNEL_ACCELEROMETER) @@ -167,12 +156,10 @@ class IASZone(BinarySensor): """Return device class from component DEVICE_CLASSES.""" return CLASS_MAPPING.get(self._channel.cluster.get("zone_type")) - async def async_update(self) -> None: - """Attempt to retrieve on off state from the binary sensor.""" - await super().async_update() - value = await self._channel.get_attribute_value("zone_status") - if value is not None: - self._state = value & 3 + @staticmethod + def parse(value: bool | int) -> bool: + """Parse the raw attribute into a bool state.""" + return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state @MULTI_MATCH( diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index b5a8d5d8cf59..404e4a8d258c 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any from zigpy.exceptions import ZigbeeException import zigpy.zcl from zigpy.zcl.clusters import security -from zigpy.zcl.clusters.security import IasAce as AceCluster +from zigpy.zcl.clusters.security import IasAce as AceCluster, IasZone from homeassistant.core import callback @@ -332,21 +332,22 @@ class IasWd(ZigbeeChannel): ) -@registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasZone.cluster_id) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(IasZone.cluster_id) class IASZoneChannel(ZigbeeChannel): """Channel for the IASZone Zigbee cluster.""" - ZCL_INIT_ATTRS = {"zone_status": True, "zone_state": False, "zone_type": True} + ZCL_INIT_ATTRS = {"zone_status": False, "zone_state": True, "zone_type": True} @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" if command_id == 0: - state = args[0] & 3 - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 2, "zone_status", state + zone_status = args[0] + # update attribute cache with new zone status + self.cluster.update_attribute( + IasZone.attributes_by_name["zone_status"].id, zone_status ) - self.debug("Updated alarm state: %s", state) + self.debug("Updated alarm state: %s", zone_status) elif command_id == 1: self.debug("Enroll requested") res = self._cluster.enroll_response(0, 0) @@ -389,11 +390,10 @@ class IASZoneChannel(ZigbeeChannel): @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" - if attrid == 2: - value = value & 3 + if attrid == IasZone.attributes_by_name["zone_status"].id: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, - self.cluster.attributes.get(attrid, [attrid])[0], + "zone_status", value, ) diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 58264bf66645..d633e9173e72 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -75,12 +75,17 @@ async def async_test_iaszone_on_off(hass, cluster, entity_id): await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + # check that binary sensor remains off when non-alarm bits change + cluster.listener_event("cluster_command", 1, 0, [0b1111111100]) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF + @pytest.mark.parametrize( ("device", "on_off_test", "cluster_name", "reporting"), [ (DEVICE_IAS, async_test_iaszone_on_off, "ias_zone", (0,)), - # (DEVICE_OCCUPANCY, async_test_binary_sensor_on_off, "occupancy", (1,)), + (DEVICE_OCCUPANCY, async_test_binary_sensor_on_off, "occupancy", (1,)), ], ) async def test_binary_sensor( From 9ccd43e5f1fe6e3e01a3f61d77475dc27e6ee5bc Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Tue, 28 Mar 2023 09:57:24 -0700 Subject: [PATCH 0907/1058] Add DataUpdateCoordinator to the Todoist integration (#89836) Co-authored-by: Franck Nijhof --- homeassistant/components/todoist/calendar.py | 28 ++++++++++------- .../components/todoist/coordinator.py | 31 +++++++++++++++++++ tests/components/todoist/test_calendar.py | 24 ++++++++++++++ 3 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/todoist/coordinator.py diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 645fea865ea6..c3e8f61fcc89 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -23,6 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt from .const import ( @@ -54,6 +55,7 @@ from .const import ( START, SUMMARY, ) +from .coordinator import TodoistCoordinator from .types import CalData, CustomProject, ProjectData, TodoistEvent _LOGGER = logging.getLogger(__name__) @@ -117,6 +119,8 @@ async def async_setup_platform( project_id_lookup = {} api = TodoistAPIAsync(token) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) + await coordinator.async_config_entry_first_refresh() # Setup devices: # Grab all projects. @@ -131,7 +135,7 @@ async def async_setup_platform( # Project is an object, not a dict! # Because of that, we convert what we need to a dict. project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} - project_devices.append(TodoistProjectEntity(project_data, labels, api)) + project_devices.append(TodoistProjectEntity(coordinator, project_data, labels)) # Cache the names so we can easily look up name->ID. project_id_lookup[project.name.lower()] = project.id @@ -157,9 +161,9 @@ async def async_setup_platform( # Create the custom project and add it to the devices array. project_devices.append( TodoistProjectEntity( + coordinator, {"id": None, "name": extra_project["name"]}, labels, - api, due_date_days=project_due_date, whitelisted_labels=project_label_filter, whitelisted_projects=project_id_filter, @@ -267,23 +271,24 @@ async def async_setup_platform( ) -class TodoistProjectEntity(CalendarEntity): +class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity): """A device for getting the next Task from a Todoist Project.""" def __init__( self, + coordinator: TodoistCoordinator, data: ProjectData, labels: list[Label], - api: TodoistAPIAsync, due_date_days: int | None = None, whitelisted_labels: list[str] | None = None, whitelisted_projects: list[str] | None = None, ) -> None: """Create the Todoist Calendar Entity.""" + super().__init__(coordinator=coordinator) self.data = TodoistProjectData( data, labels, - api, + coordinator, due_date_days=due_date_days, whitelisted_labels=whitelisted_labels, whitelisted_projects=whitelisted_projects, @@ -306,6 +311,7 @@ class TodoistProjectEntity(CalendarEntity): async def async_update(self) -> None: """Update all Todoist Calendars.""" + await super().async_update() await self.data.async_update() # Set Todoist-specific data that can't easily be grabbed self._cal_data["all_tasks"] = [ @@ -373,7 +379,7 @@ class TodoistProjectData: self, project_data: ProjectData, labels: list[Label], - api: TodoistAPIAsync, + coordinator: TodoistCoordinator, due_date_days: int | None = None, whitelisted_labels: list[str] | None = None, whitelisted_projects: list[str] | None = None, @@ -381,7 +387,7 @@ class TodoistProjectData: """Initialize a Todoist Project.""" self.event: TodoistEvent | None = None - self._api = api + self._coordinator = coordinator self._name = project_data[CONF_NAME] # If no ID is defined, fetch all tasks. self._id = project_data.get(CONF_ID) @@ -569,8 +575,8 @@ class TodoistProjectData: self, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all tasks in a specific time frame.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -578,7 +584,7 @@ class TodoistProjectData: or task.project_id in self._project_id_whitelist ] else: - project_task_data = await self._api.get_tasks(project_id=self._id) + project_task_data = [task for task in tasks if task.project_id == self._id] events = [] for task in project_task_data: @@ -607,8 +613,8 @@ class TodoistProjectData: async def async_update(self) -> None: """Get the latest data.""" + tasks = self._coordinator.data if self._id is None: - tasks = await self._api.get_tasks() project_task_data = [ task for task in tasks @@ -616,7 +622,7 @@ class TodoistProjectData: or task.project_id in self._project_id_whitelist ] else: - project_task_data = await self._api.get_tasks(project_id=self._id) + project_task_data = [task for task in tasks if task.project_id == self._id] # If we have no data, we can just return right away. if not project_task_data: diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py new file mode 100644 index 000000000000..b573d1d11277 --- /dev/null +++ b/homeassistant/components/todoist/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for the Todoist component.""" +from datetime import timedelta +import logging + +from todoist_api_python.api_async import TodoistAPIAsync +from todoist_api_python.models import Task + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + + +class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): + """Coordinator for updating task data from Todoist.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + update_interval: timedelta, + api: TodoistAPIAsync, + ) -> None: + """Initialize the Todoist coordinator.""" + super().__init__(hass, logger, name="Todoist", update_interval=update_interval) + self.api = api + + async def _async_update_data(self) -> list[Task]: + """Fetch tasks from the Todoist API.""" + try: + return await self.api.get_tasks() + except Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 9c0680d14434..4f792b3cc01b 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -132,6 +132,30 @@ async def test_update_entity_for_custom_project_with_labels_on( assert state.state == "on" +@patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") +async def test_failed_coordinator_update(todoist_api, hass: HomeAssistant, api) -> None: + """Test a failed data coordinator update is handled correctly.""" + api.get_tasks.side_effect = Exception("API error") + todoist_api.return_value = api + + assert await setup.async_setup_component( + hass, + "calendar", + { + "calendar": { + "platform": DOMAIN, + CONF_TOKEN: "token", + "custom_projects": [{"name": "All projects", "labels": ["Label1"]}], + } + }, + ) + await hass.async_block_till_done() + + await async_update_entity(hass, "calendar.all_projects") + state = hass.states.get("calendar.all_projects") + assert state is None + + @patch("homeassistant.components.todoist.calendar.TodoistAPIAsync") async def test_calendar_custom_project_unique_id( todoist_api, hass: HomeAssistant, api, entity_registry: er.EntityRegistry From d21433b6af99a2223c7895b0300d5e9ae9018238 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 08:50:10 -1000 Subject: [PATCH 0908/1058] Ensure filters are generated inside the lambda locks (#90418) --- .../components/logbook/queries/__init__.py | 7 +----- .../components/logbook/queries/all.py | 25 ++++++++----------- homeassistant/components/recorder/filters.py | 14 ++++++----- .../components/recorder/history/legacy.py | 8 +++--- .../components/recorder/history/modern.py | 10 +++++--- tests/components/recorder/test_filters.py | 24 ++++++++++++++++++ 6 files changed, 54 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 0172700df437..b83f7a4428ae 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -34,16 +34,11 @@ def statement_for_request( # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: context_id_bin = ulid_to_bytes_or_none(context_id) - states_entity_filter = ( - filters.states_metadata_entity_filter() if filters else None - ) - events_entity_filter = filters.events_entity_filter() if filters else None return all_stmt( start_day, end_day, event_types, - states_entity_filter, - events_entity_filter, + filters, context_id_bin, ) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index 8c37bf22da92..70214fbb04ba 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -2,7 +2,6 @@ from __future__ import annotations from sqlalchemy import lambda_stmt -from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select @@ -11,6 +10,7 @@ from homeassistant.components.recorder.db_schema import ( Events, States, ) +from homeassistant.components.recorder.filters import Filters from .common import apply_states_filters, select_events_without_states, select_states @@ -19,8 +19,7 @@ def all_stmt( start_day: float, end_day: float, event_types: tuple[str, ...], - states_entity_filter: ColumnElement | None = None, - events_entity_filter: ColumnElement | None = None, + filters: Filters | None, context_id_bin: bytes | None = None, ) -> StatementLambdaElement: """Generate a logbook query for all entities.""" @@ -36,19 +35,17 @@ def all_stmt( context_id_bin, # type:ignore[arg-type] ), ) - else: - if events_entity_filter is not None: - stmt += lambda s: s.where(events_entity_filter) - - if states_entity_filter is not None: - stmt += lambda s: s.union_all( + elif filters and filters.has_config: + stmt = stmt.add_criteria( + lambda q: q.filter(filters.events_entity_filter()).union_all( # type: ignore[union-attr] _states_query_for_all(start_day, end_day).where( - # https://github.com/python/mypy/issues/2608 - states_entity_filter # type:ignore[arg-type] + filters.states_metadata_entity_filter() # type: ignore[union-attr] ) - ) - else: - stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) + ), + track_on=[filters], + ) + else: + stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) stmt += lambda s: s.order_by(Events.time_fired_ts) return stmt diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 63eed2d14540..de0929cf9f40 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -125,8 +125,8 @@ class Filters: def _generate_filter_for_columns( self, columns: Iterable[Column], encoder: Callable[[Any], Any] - ) -> ColumnElement | None: - """Generate a filter from pre-comuted sets and pattern lists. + ) -> ColumnElement: + """Generate a filter from pre-computed sets and pattern lists. This must match exactly how homeassistant.helpers.entityfilter works. """ @@ -146,7 +146,9 @@ class Filters: # Case 1 - No filter # - All entities included if not have_include and not have_exclude: - return None + raise RuntimeError( + "No filter configuration provided, check has_config before calling this method." + ) # Case 2 - Only includes # - Entity listed in entities include: include @@ -193,7 +195,7 @@ class Filters: # - Otherwise: exclude return i_entities - def states_entity_filter(self) -> ColumnElement | None: + def states_entity_filter(self) -> ColumnElement: """Generate the States.entity_id filter query. This is no longer used except by the legacy queries. @@ -206,7 +208,7 @@ class Filters: # The type annotation should be improved so the type ignore can be removed return self._generate_filter_for_columns((States.entity_id,), _encoder) # type: ignore[arg-type] - def states_metadata_entity_filter(self) -> ColumnElement | None: + def states_metadata_entity_filter(self) -> ColumnElement: """Generate the StatesMeta.entity_id filter query.""" def _encoder(data: Any) -> Any: @@ -232,7 +234,7 @@ class Filters: (OLD_ENTITY_ID_IN_EVENT == JSON_NULL) | OLD_ENTITY_ID_IN_EVENT.is_(None) ), # Needs https://github.com/bdraco/home-assistant/commit/bba91945006a46f3a01870008eb048e4f9cbb1ef - self._generate_filter_for_columns( # type: ignore[union-attr] + self._generate_filter_for_columns( (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder # type: ignore[arg-type] ).self_group(), ) diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index e51b1a256860..c33825a767cd 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -306,9 +306,8 @@ def _significant_states_stmt( else: stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_entity_filter() stmt = stmt.add_criteria( - lambda q: q.filter(entity_filter), track_on=[filters] + lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] ) if schema_version >= 31: @@ -713,8 +712,9 @@ def _get_states_for_all_stmt( ) stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_entity_filter() - stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) + stmt = stmt.add_criteria( + lambda q: q.filter(filters.states_entity_filter()), track_on=[filters] # type: ignore[union-attr] + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 22bfdc3ee94a..f7d08c6bba80 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -192,9 +192,9 @@ def _significant_states_stmt( else: stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_metadata_entity_filter() stmt = stmt.add_criteria( - lambda q: q.filter(entity_filter), track_on=[filters] + lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] + track_on=[filters], ) join_states_meta = True @@ -567,8 +567,10 @@ def _get_states_for_all_stmt( ) stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.states_metadata_entity_filter() - stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) + stmt = stmt.add_criteria( + lambda q: q.filter(filters.states_metadata_entity_filter()), # type: ignore[union-attr] + track_on=[filters], + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) diff --git a/tests/components/recorder/test_filters.py b/tests/components/recorder/test_filters.py index 7f7d12364e48..13a2a325f1e9 100644 --- a/tests/components/recorder/test_filters.py +++ b/tests/components/recorder/test_filters.py @@ -1,6 +1,9 @@ """The tests for recorder filters.""" +import pytest + from homeassistant.components.recorder.filters import ( + Filters, extract_include_exclude_filter_conf, merge_include_exclude_filters, ) @@ -132,3 +135,24 @@ def test_merge_include_exclude_filters() -> None: CONF_ENTITY_GLOBS: {"climate.*", "not_climate.*"}, }, } + + +async def test_an_empty_filter_raises() -> None: + """Test empty filter raises when not guarding with has_config.""" + filters = Filters() + assert not filters.has_config + with pytest.raises( + RuntimeError, + match="No filter configuration provided, check has_config before calling this method", + ): + filters.states_metadata_entity_filter() + with pytest.raises( + RuntimeError, + match="No filter configuration provided, check has_config before calling this method", + ): + filters.states_entity_filter() + with pytest.raises( + RuntimeError, + match="No filter configuration provided, check has_config before calling this method", + ): + filters.events_entity_filter() From 24d0d15f38a33b2ee8387d2ed45640b7f45663c4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Mar 2023 21:02:43 +0200 Subject: [PATCH 0909/1058] Implement imap_content event for imap integration (#90242) --- .coveragerc | 3 - homeassistant/components/imap/coordinator.py | 111 ++++++- tests/components/imap/conftest.py | 104 +++++- tests/components/imap/const.py | 139 ++++++++ tests/components/imap/test_init.py | 323 +++++++++++++++++++ 5 files changed, 671 insertions(+), 9 deletions(-) create mode 100644 tests/components/imap/const.py create mode 100644 tests/components/imap/test_init.py diff --git a/.coveragerc b/.coveragerc index 82677177e642..4b831fc3d3c2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -518,9 +518,6 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/imap/__init__.py - homeassistant/components/imap/coordinator.py - homeassistant/components/imap/sensor.py homeassistant/components/imap_email_content/sensor.py homeassistant/components/incomfort/* homeassistant/components/insteon/binary_sensor.py diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 69f291df6eb8..76eb8e46f533 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping from datetime import timedelta +import email import logging from typing import Any @@ -11,7 +12,12 @@ from aioimaplib import AUTH, IMAP4_SSL, SELECTED, AioImapException import async_timeout from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONTENT_TYPE_TEXT_PLAIN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,6 +29,8 @@ _LOGGER = logging.getLogger(__name__) BACKOFF_TIME = 10 +EVENT_IMAP = "imap_content" + async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" @@ -37,6 +45,70 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: return client +class ImapMessage: + """Class to parse an RFC822 email message.""" + + def __init__(self, raw_message: bytes) -> None: + """Initialize IMAP message.""" + self.email_message = email.message_from_bytes(raw_message) + + @property + def headers(self) -> dict[str, tuple[str,]]: + """Get the email headers.""" + header_base: dict[str, tuple[str,]] = {} + for key, value in self.email_message.items(): + header: tuple[str,] = (str(value),) + if header_base.setdefault(key, header) != header: + header_base[key] += header # type: ignore[assignment] + return header_base + + @property + def sender(self) -> str: + """Get the parsed message sender from the email.""" + return str(email.utils.parseaddr(self.email_message["From"])[1]) + + @property + def subject(self) -> str: + """Decode the message subject.""" + decoded_header = email.header.decode_header(self.email_message["Subject"]) + header = email.header.make_header(decoded_header) + return str(header) + + @property + def text(self) -> str: + """Get the message text from the email. + + Will look for text/plain or use text/html if not found. + """ + message_text = None + message_html = None + message_untyped_text = None + + for part in self.email_message.walk(): + if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: + if message_text is None: + message_text = part.get_payload() + elif part.get_content_type() == "text/html": + if message_html is None: + message_html = part.get_payload() + elif ( + part.get_content_type().startswith("text") + and message_untyped_text is None + ): + message_untyped_text = part.get_payload() + + if message_text is not None: + return message_text + + if message_html is not None: + return message_html + + if message_untyped_text is not None: + return message_untyped_text + + return self.email_message.get_payload() + + class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" @@ -50,6 +122,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): ) -> None: """Initiate imap client.""" self.imap_client = imap_client + self._last_message_id: str | None = None super().__init__( hass, _LOGGER, @@ -65,8 +138,30 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): if self.imap_client is None: self.imap_client = await connect_to_server(self.config_entry.data) + async def _async_process_event(self, last_message_id: str) -> None: + """Send a event for the last message if the last message was changed.""" + response = await self.imap_client.fetch(last_message_id, "BODY.PEEK[]") + if response.result == "OK": + message = ImapMessage(response.lines[1]) + data = { + "server": self.config_entry.data[CONF_SERVER], + "username": self.config_entry.data[CONF_USERNAME], + "search": self.config_entry.data[CONF_SEARCH], + "folder": self.config_entry.data[CONF_FOLDER], + "text": message.text, + "sender": message.sender, + "subject": message.subject, + "headers": message.headers, + } + self.hass.bus.fire(EVENT_IMAP, data) + _LOGGER.debug( + "Message processed, sender: %s, subject: %s", + message.sender, + message.subject, + ) + async def _async_fetch_number_of_messages(self) -> int | None: - """Fetch number of messages.""" + """Fetch last message and messages count.""" await self._async_reconnect_if_needed() await self.imap_client.noop() result, lines = await self.imap_client.search( @@ -77,7 +172,17 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): raise UpdateFailed( f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) - return len(lines[0].split()) + count: int = len(message_ids := lines[0].split()) + last_message_id = ( + str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) + if count + else None + ) + if count and last_message_id is not None: + self._last_message_id = last_message_id + await self._async_process_event(last_message_id) + + return count async def _cleanup(self, log_error: bool = False) -> None: """Close resources.""" diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py index bc82cf57d816..74176efab111 100644 --- a/tests/components/imap/conftest.py +++ b/tests/components/imap/conftest.py @@ -1,9 +1,13 @@ -"""Test the iamp config flow.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +"""Fixtures for imap tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from aioimaplib import AUTH, LOGOUT, NONAUTH, SELECTED, STARTED, Response import pytest +from .const import EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -12,3 +16,97 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.imap.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def imap_has_capability() -> bool: + """Fixture to set the imap capabilities.""" + return True + + +@pytest.fixture +def imap_login_state() -> str: + """Fixture to set the imap state after login.""" + return AUTH + + +@pytest.fixture +def imap_select_state() -> str: + """Fixture to set the imap capabilities.""" + return SELECTED + + +@pytest.fixture +def imap_search() -> tuple[str, list[bytes]]: + """Fixture to set the imap search response.""" + return EMPTY_SEARCH_RESPONSE + + +@pytest.fixture +def imap_fetch() -> tuple[str, list[bytes | bytearray]]: + """Fixture to set the imap fetch response.""" + return TEST_FETCH_RESPONSE_TEXT_PLAIN + + +@pytest.fixture +def imap_pending_idle() -> bool: + """Fixture to set the imap pending idle feature.""" + return True + + +@pytest.fixture +async def mock_imap_protocol( + imap_search: tuple[str, list[bytes]], + imap_fetch: tuple[str, list[bytes | bytearray]], + imap_has_capability: bool, + imap_pending_idle: bool, + imap_login_state: str, + imap_select_state: str, +) -> Generator[MagicMock, None]: + """Mock the aioimaplib IMAP protocol handler.""" + + with patch( + "homeassistant.components.imap.coordinator.IMAP4_SSL", autospec=True + ) as imap_mock: + imap_mock = imap_mock.return_value + + async def login(user: str, password: str) -> Response: + """Mock imap login.""" + imap_mock.protocol.state = imap_login_state + if imap_login_state != AUTH: + return Response("BAD", []) + return Response("OK", [b"CAPABILITY IMAP4rev1 ...", b"Logged in"]) + + async def close() -> Response: + """Mock imap close the selected folder.""" + imap_mock.protocol.state = imap_login_state + return Response("OK", []) + + async def logout() -> Response: + """Mock imap logout.""" + imap_mock.protocol.state = LOGOUT + return Response("OK", []) + + async def select(mailbox: str = "INBOX") -> Response: + """Mock imap folder select.""" + imap_mock.protocol.state = imap_select_state + if imap_login_state != SELECTED: + return Response("BAD", []) + return Response("OK", []) + + async def wait_hello_from_server() -> None: + """Mock wait for hello.""" + imap_mock.protocol.state = NONAUTH + + imap_mock.has_pending_idle.return_value = imap_pending_idle + imap_mock.protocol = MagicMock() + imap_mock.protocol.state = STARTED + imap_mock.has_capability.return_value = imap_has_capability + imap_mock.login.side_effect = login + imap_mock.close.side_effect = close + imap_mock.logout.side_effect = logout + imap_mock.select.side_effect = select + imap_mock.search.return_value = Response(*imap_search) + imap_mock.fetch.return_value = Response(*imap_fetch) + imap_mock.wait_hello_from_server.side_effect = wait_hello_from_server + yield imap_mock diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py new file mode 100644 index 000000000000..68fab7d38cbb --- /dev/null +++ b/tests/components/imap/const.py @@ -0,0 +1,139 @@ +"""Constants for tests imap integration.""" + +TEST_MESSAGE = ( + b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" + b"Received: from beta.example.com\r\n\tby beta with LMTP\r\n\t" + b"id eLp2M/GcHWQTLxQAho4UZQ\r\n\t(envelope-from )\r\n\t" + b"for ; Fri, 24 Mar 2023 13:52:01 +0100\r\n" + b"Received: from localhost (localhost [127.0.0.1])\r\n\t" + b"by beta.example.com (Postfix) with ESMTP id D0FFA61425\r\n\t" + b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" + b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" + b"MIME-Version: 1.0\r\n" + b"To: notify@example.com\r\n" + b"From: John Doe \r\n" + b"Subject: Test subject\r\n" +) + +TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" + +TEST_CONTENT_BINARY = ( + b"Content-Type: application/binary\r\n" + b"Content-Transfer-Encoding: base64\r\n" + b"\r\n" + b"VGVzdCBib2R5\r\n" +) + +TEST_CONTENT_TEXT_PLAIN = ( + b"Content-Type: text/plain; charset=UTF-8; format=flowed\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" +) + +TEST_CONTENT_TEXT_OTHER = ( + b"Content-Type: text/other; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n\r\n" +) + +TEST_CONTENT_HTML = ( + b"Content-Type: text/html; charset=UTF-8\r\n" + b"Content-Transfer-Encoding: 7bit\r\n" + b"\r\n" + b"\r\n" + b" \r\n" + b' \r\n' + b" \r\n" + b" \r\n" + b"

Test body
\r\n" + b"

\r\n" + b" \r\n" + b"\r\n" + b"\r\n" +) + +TEST_CONTENT_MULTIPART = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + TEST_CONTENT_TEXT_PLAIN + + b"--------------McwBciN2C0o3rWeF1tmFo2oI\r\n" + + TEST_CONTENT_HTML + + b"--------------McwBciN2C0o3rWeF1tmFo2oI--\r\n" +) + +EMPTY_SEARCH_RESPONSE = ("OK", [b"", b"Search completed (0.0001 + 0.000 secs)."]) +BAD_RESPONSE = ("BAD", [b"", b"Unexpected error"]) + +TEST_SEARCH_RESPONSE = ("OK", [b"1", b"Search completed (0.0001 + 0.000 secs)."]) + +TEST_FETCH_RESPONSE_TEXT_BARE = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_BARE)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_BARE), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_TEXT_PLAIN = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_TEXT_OTHER = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_OTHER)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_OTHER), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_BINARY = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_BINARY)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_BINARY), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_HTML = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_HTML)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_HTML), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_MULTIPART = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_MULTIPART)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_MULTIPART), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +RESPONSE_BAD = ("BAD", []) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py new file mode 100644 index 000000000000..ec9058830ddc --- /dev/null +++ b/tests/components/imap/test_init.py @@ -0,0 +1,323 @@ +"""Test the imap entry initialization.""" +import asyncio +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException, Response +import pytest + +from homeassistant.components.imap import DOMAIN +from homeassistant.components.imap.errors import InvalidAuth, InvalidFolder +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .const import ( + BAD_RESPONSE, + TEST_FETCH_RESPONSE_BINARY, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_SEARCH_RESPONSE, +) +from .test_config_flow import MOCK_CONFIG + +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_entry_startup_and_unload( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test imap entry startup and unload with push and polling coordinator.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert await config_entry.async_unload(hass) + + +@pytest.mark.parametrize( + "effect", + [ + InvalidAuth, + InvalidFolder, + asyncio.TimeoutError, + ], +) +async def test_entry_startup_fails( + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + effect: Exception, +) -> None: + """Test imap entry startup fails on invalid auth or folder.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.imap.connect_to_server", + side_effect=effect, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is False + + +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + "imap_fetch", + [ + TEST_FETCH_RESPONSE_TEXT_BARE, + TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_FETCH_RESPONSE_TEXT_OTHER, + TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_MULTIPART, + TEST_FETCH_RESPONSE_BINARY, + ], + ids=["bare", "plain", "other", "html", "multipart", "binary"], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_receiving_message_successfully( + hass: HomeAssistant, mock_imap_protocol: MagicMock +) -> None: + """Test receiving a message successfully.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_login_state", "success"), [(AUTH, True), (NONAUTH, False)] +) +async def test_initial_authentication_error( + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool +) -> None: + """Test authentication error when starting the entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + assert (state is not None) == success + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("imap_select_state", "success"), [(AUTH, False), (SELECTED, True)] +) +async def test_initial_invalid_folder_error( + hass: HomeAssistant, mock_imap_protocol: MagicMock, success: bool +) -> None: + """Test invalid folder error when starting the entry.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) == success + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + assert (state is not None) == success + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_late_authentication_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test authentication error handling after a search was failed.""" + + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = AioImapException( + "Something went wrong" + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + # Mock that the search fails, this will trigger + # that the connection will be restarted + # Then fail selecting the folder + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + mock_imap_protocol.login.side_effect = Response(*BAD_RESPONSE) + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert "Username or password incorrect, starting reauthentication" in caplog.text + + # we still should have an entity with an unavailable state + state = hass.states.get("sensor.imap_email_email_com") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_late_folder_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test invalid folder error handling after a search was failed. + + Asserting the IMAP push coordinator. + """ + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = AioImapException( + "Something went wrong" + ) + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + + # Mock that the search fails, this will trigger + # that the connection will be restarted + # Then fail selecting the folder + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + mock_imap_protocol.select.side_effect = Response(*BAD_RESPONSE) + + # Make sure we have had at least one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=60)) + await hass.async_block_till_done() + assert "Selected mailbox folder is invalid" in caplog.text + + # we still should have an entity with an unavailable state + state = hass.states.get("sensor.imap_email_email_com") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + "imap_close", + [ + AsyncMock(side_effect=AioImapException("Something went wrong")), + AsyncMock(side_effect=asyncio.TimeoutError), + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_handle_cleanup_exception( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_close: Exception, +) -> None: + """Test handling an excepton during cleaning up.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity + assert state is not None + assert state.state == "0" + + # Fail cleaning up + mock_imap_protocol.close.side_effect = imap_close + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert "Error while cleaning up imap connection" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) +@pytest.mark.parametrize( + "imap_wait_server_push_exception", + [ + AioImapException("Something went wrong"), + asyncio.TimeoutError, + ], + ids=["AioImapException", "TimeoutError"], +) +async def test_lost_connection_with_imap_push( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, + imap_wait_server_push_exception: AioImapException | asyncio.TimeoutError, +) -> None: + """Test error handling when the connection is lost.""" + # Mock an error in waiting for a pushed update + mock_imap_protocol.wait_server_push.side_effect = imap_wait_server_push_exception + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Lost imap.server.com (will attempt to reconnect after 10 s)" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) +async def test_fetch_number_of_messages( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_imap_protocol: MagicMock, +) -> None: + """Test _async_fetch_number_of_messages fails with push coordinator.""" + # Mock an error in waiting for a pushed update + mock_imap_protocol.search.return_value = Response(*BAD_RESPONSE) + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we wait for the backoff time + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert "Invalid response for search" in caplog.text + + state = hass.states.get("sensor.imap_email_email_com") + # we should have an entity with an unavailable state + assert state is not None + assert state.state == STATE_UNAVAILABLE From e9925f6062bb6e60a540de26d1f2367572462795 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 28 Mar 2023 22:46:59 +0200 Subject: [PATCH 0910/1058] Check webhook url is reachable in Reolink (#89585) Co-authored-by: Franck Nijhof --- homeassistant/components/reolink/host.py | 42 ++++++++++++++++--- homeassistant/components/reolink/strings.json | 6 ++- tests/components/reolink/conftest.py | 2 + tests/components/reolink/test_init.py | 19 ++++++++- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 9ba4809e90d5..1c0f97b6a2d6 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -54,7 +54,9 @@ class ReolinkHost: ) self.webhook_id: str | None = None - self._webhook_url: str | None = None + self._base_url: str = "" + self._webhook_url: str = "" + self._webhook_reachable: asyncio.Event = asyncio.Event() self._lost_subscription: bool = False @property @@ -138,6 +140,32 @@ class ReolinkHost: await self.subscribe() + _LOGGER.debug( + "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url + ) + try: + await asyncio.wait_for(self._webhook_reachable.wait(), timeout=15) + except asyncio.TimeoutError: + _LOGGER.debug( + "Did not receive initial ONVIF state on webhook '%s' after 15 seconds", + self._webhook_url, + ) + ir.async_create_issue( + self._hass, + DOMAIN, + "webhook_url", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="webhook_url", + translation_placeholders={ + "name": self._api.nvr_name, + "base_url": self._base_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + if self._api.sw_version_update_required: ir.async_create_issue( self._hass, @@ -287,10 +315,10 @@ class ReolinkHost: ) try: - base_url = get_url(self._hass, prefer_external=False) + self._base_url = get_url(self._hass, prefer_external=False) except NoURLAvailableError: try: - base_url = get_url(self._hass, prefer_external=True) + self._base_url = get_url(self._hass, prefer_external=True) except NoURLAvailableError as err: self.unregister_webhook() raise ReolinkWebhookException( @@ -299,9 +327,9 @@ class ReolinkHost: ) from err webhook_path = webhook.async_generate_path(event_id) - self._webhook_url = f"{base_url}{webhook_path}" + self._webhook_url = f"{self._base_url}{webhook_path}" - if base_url.startswith("https"): + if self._base_url.startswith("https"): ir.async_create_issue( self._hass, DOMAIN, @@ -310,7 +338,7 @@ class ReolinkHost: severity=ir.IssueSeverity.WARNING, translation_key="https_webhook", translation_placeholders={ - "base_url": base_url, + "base_url": self._base_url, "network_link": "https://my.home-assistant.io/redirect/network/", }, ) @@ -337,6 +365,8 @@ class ReolinkHost: """Handle incoming webhook from Reolink for inbound messages and calls.""" _LOGGER.debug("Webhook '%s' called", webhook_id) + if not self._webhook_reachable.is_set(): + self._webhook_reachable.set() if not request.body_exists: _LOGGER.debug("Webhook '%s' triggered without payload", webhook_id) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 74759c12f988..50c561530e57 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -41,7 +41,11 @@ "issues": { "https_webhook": { "title": "Reolink webhook URL uses HTTPS (SSL)", - "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`" + "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device" + }, + "webhook_url": { + "title": "Reolink webhook URL unreachable", + "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL." }, "enable_port": { "title": "Reolink port not enabled", diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 941a1ca7c878..be748ef2c400 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -39,6 +39,8 @@ def reolink_connect(mock_get_source_ip: None) -> Generator[MagicMock, None, None with patch( "homeassistant.components.reolink.host.webhook.async_register", return_value=True, + ), patch( + "homeassistant.components.reolink.host.asyncio.Event.wait", AsyncMock() ), patch( "homeassistant.components.reolink.host.Host", autospec=True ) as host_mock_class: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 8849c7d52d3f..57d0dbd7cb72 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1,6 +1,7 @@ """Test the Reolink init.""" +import asyncio from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from reolink_aio.exceptions import ReolinkError @@ -99,6 +100,7 @@ async def test_no_repair_issue( issue_registry = ir.async_get(hass) assert (const.DOMAIN, "https_webhook") not in issue_registry.issues + assert (const.DOMAIN, "webhook_url") not in issue_registry.issues assert (const.DOMAIN, "enable_port") not in issue_registry.issues assert (const.DOMAIN, "firmware_update") not in issue_registry.issues @@ -138,6 +140,21 @@ async def test_port_repair_issue( assert (const.DOMAIN, "enable_port") in issue_registry.issues +async def test_webhook_repair_issue( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test repairs issue is raised when the webhook url is unreachable.""" + with patch( + "homeassistant.components.reolink.host.asyncio.Event.wait", + AsyncMock(side_effect=asyncio.TimeoutError()), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + assert (const.DOMAIN, "webhook_url") in issue_registry.issues + + async def test_firmware_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_connect: MagicMock ) -> None: From 0ceee2b6c3a632caa25df8b76a6db5dfdb89fbb1 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Mar 2023 16:48:27 -0400 Subject: [PATCH 0911/1058] Catch somecomfort error in Honeywell (#90425) --- homeassistant/components/honeywell/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 93c29446a531..ff5448822894 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -63,6 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except ( aiosomecomfort.device.ConnectionError, aiosomecomfort.device.ConnectionTimeout, + aiosomecomfort.device.SomeComfortError, asyncio.TimeoutError, ) as ex: raise ConfigEntryNotReady( From 93e1cd8dd8064e2594ad10993cdd26d64523e5f5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 28 Mar 2023 22:50:25 +0200 Subject: [PATCH 0912/1058] Add header with parsed date to imap event data (#90422) --- homeassistant/components/imap/coordinator.py | 16 +++++++- tests/components/imap/const.py | 39 +++++++++++++++++++- tests/components/imap/test_init.py | 39 +++++++++++++++----- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 76eb8e46f533..97432b910543 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.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 email import logging from typing import Any @@ -62,6 +62,19 @@ class ImapMessage: header_base[key] += header # type: ignore[assignment] return header_base + @property + def date(self) -> datetime | None: + """Get the date the email was sent.""" + # See https://www.rfc-editor.org/rfc/rfc2822#section-3.3 + date_str: str | None + if (date_str := self.email_message["Date"]) is None: + return None + # In some cases a timezone or comment is added in parenthesis after the date + # We want to strip that part to avoid parsing errors + return datetime.strptime( + date_str.split("(")[0].strip(), "%a, %d %b %Y %H:%M:%S %z" + ) + @property def sender(self) -> str: """Get the parsed message sender from the email.""" @@ -148,6 +161,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): "username": self.config_entry.data[CONF_USERNAME], "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], + "date": message.date, "text": message.text, "sender": message.sender, "subject": message.subject, diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 68fab7d38cbb..7c774527b31b 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -1,6 +1,11 @@ """Constants for tests imap integration.""" -TEST_MESSAGE = ( + +DATE_HEADER1 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" +DATE_HEADER2 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100 (CET)\r\n" +DATE_HEADER_INVALID = b"2023-03-27T13:52:00 +0100\r\n" + +TEST_MESSAGE_HEADERS1 = ( b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" b"Received: from beta.example.com\r\n\tby beta with LMTP\r\n\t" b"id eLp2M/GcHWQTLxQAho4UZQ\r\n\t(envelope-from )\r\n\t" @@ -8,13 +13,18 @@ TEST_MESSAGE = ( b"Received: from localhost (localhost [127.0.0.1])\r\n\t" b"by beta.example.com (Postfix) with ESMTP id D0FFA61425\r\n\t" b"for ; Fri, 24 Mar 2023 13:52:01 +0100 (CET)\r\n" - b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" +) +TEST_MESSAGE_HEADERS2 = ( b"MIME-Version: 1.0\r\n" b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" ) +TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 +TEST_MESSAGE_ALT = TEST_MESSAGE_HEADERS1 + DATE_HEADER2 + TEST_MESSAGE_HEADERS2 +TEST_INVALID_DATE = TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID + TEST_MESSAGE_HEADERS2 + TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" TEST_CONTENT_BINARY = ( @@ -88,6 +98,31 @@ TEST_FETCH_RESPONSE_TEXT_PLAIN = ( ], ) +TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_ALT + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE_ALT + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + +TEST_FETCH_RESPONSE_INVALID_DATE = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + + TEST_FETCH_RESPONSE_TEXT_OTHER = ( "OK", [ diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index ec9058830ddc..fdcf37b76ba8 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -1,6 +1,6 @@ """Test the imap entry initialization.""" import asyncio -from datetime import timedelta +from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -17,10 +17,12 @@ from .const import ( BAD_RESPONSE, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, + TEST_FETCH_RESPONSE_INVALID_DATE, TEST_FETCH_RESPONSE_MULTIPART, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, TEST_FETCH_RESPONSE_TEXT_PLAIN, + TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, TEST_SEARCH_RESPONSE, ) from .test_config_flow import MOCK_CONFIG @@ -66,20 +68,31 @@ async def test_entry_startup_fails( @pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) @pytest.mark.parametrize( - "imap_fetch", + ("imap_fetch", "valid_date"), [ - TEST_FETCH_RESPONSE_TEXT_BARE, - TEST_FETCH_RESPONSE_TEXT_PLAIN, - TEST_FETCH_RESPONSE_TEXT_OTHER, - TEST_FETCH_RESPONSE_HTML, - TEST_FETCH_RESPONSE_MULTIPART, - TEST_FETCH_RESPONSE_BINARY, + (TEST_FETCH_RESPONSE_TEXT_BARE, True), + (TEST_FETCH_RESPONSE_TEXT_PLAIN, True), + (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True), + (TEST_FETCH_RESPONSE_INVALID_DATE, False), + (TEST_FETCH_RESPONSE_TEXT_OTHER, True), + (TEST_FETCH_RESPONSE_HTML, True), + (TEST_FETCH_RESPONSE_MULTIPART, True), + (TEST_FETCH_RESPONSE_BINARY, True), + ], + ids=[ + "bare", + "plain", + "plain_alt", + "invalid_date", + "other", + "html", + "multipart", + "binary", ], - ids=["bare", "plain", "other", "html", "multipart", "binary"], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_receiving_message_successfully( - hass: HomeAssistant, mock_imap_protocol: MagicMock + hass: HomeAssistant, mock_imap_protocol: MagicMock, valid_date: bool ) -> None: """Test receiving a message successfully.""" event_called = async_capture_events(hass, "imap_content") @@ -106,6 +119,12 @@ async def test_receiving_message_successfully( assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["text"] + assert ( + valid_date + and isinstance(data["date"], datetime) + or not valid_date + and data["date"] is None + ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) From 0550b17d543c471851064e8c81cd7abbd2d0660b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 10:51:46 -1000 Subject: [PATCH 0913/1058] Rework recorder filters to avoid caching mistakes (#90419) --- homeassistant/components/recorder/filters.py | 89 +++++++++++-------- tests/components/history/test_init.py | 19 ++-- .../history/test_init_db_schema_30.py | 19 ++-- 3 files changed, 68 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index de0929cf9f40..24d22704a89b 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -63,42 +63,53 @@ def merge_include_exclude_filters( def sqlalchemy_filter_from_include_exclude_conf(conf: ConfigType) -> Filters | None: """Build a sql filter from config.""" - filters = Filters() - if exclude := conf.get(CONF_EXCLUDE): - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - filters.excluded_entity_globs = exclude.get(CONF_ENTITY_GLOBS, []) - if include := conf.get(CONF_INCLUDE): - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - filters.included_entity_globs = include.get(CONF_ENTITY_GLOBS, []) - + exclude = conf.get(CONF_EXCLUDE, {}) + include = conf.get(CONF_INCLUDE, {}) + filters = Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + excluded_entity_globs=exclude.get(CONF_ENTITY_GLOBS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + included_entity_globs=include.get(CONF_ENTITY_GLOBS, []), + ) return filters if filters.has_config else None class Filters: - """Container for the configured include and exclude filters.""" + """Container for the configured include and exclude filters. - def __init__(self) -> None: + A filter must never change after it is created since it is used in a + cache key. + """ + + def __init__( + self, + excluded_entities: Collection[str] | None = None, + excluded_domains: Collection[str] | None = None, + excluded_entity_globs: Collection[str] | None = None, + included_entities: Collection[str] | None = None, + included_domains: Collection[str] | None = None, + included_entity_globs: Collection[str] | None = None, + ) -> None: """Initialise the include and exclude filters.""" - self.excluded_entities: Collection[str] = [] - self.excluded_domains: Collection[str] = [] - self.excluded_entity_globs: Collection[str] = [] - - self.included_entities: Collection[str] = [] - self.included_domains: Collection[str] = [] - self.included_entity_globs: Collection[str] = [] + self._excluded_entities = excluded_entities or [] + self._excluded_domains = excluded_domains or [] + self._excluded_entity_globs = excluded_entity_globs or [] + self._included_entities = included_entities or [] + self._included_domains = included_domains or [] + self._included_entity_globs = included_entity_globs or [] def __repr__(self) -> str: """Return human readable excludes/includes.""" return ( "" ) @@ -110,17 +121,17 @@ class Filters: @property def _have_exclude(self) -> bool: return bool( - self.excluded_entities - or self.excluded_domains - or self.excluded_entity_globs + self._excluded_entities + or self._excluded_domains + or self._excluded_entity_globs ) @property def _have_include(self) -> bool: return bool( - self.included_entities - or self.included_domains - or self.included_entity_globs + self._included_entities + or self._included_domains + or self._included_entity_globs ) def _generate_filter_for_columns( @@ -130,14 +141,14 @@ class Filters: This must match exactly how homeassistant.helpers.entityfilter works. """ - i_domains = _domain_matcher(self.included_domains, columns, encoder) - i_entities = _entity_matcher(self.included_entities, columns, encoder) - i_entity_globs = _globs_to_like(self.included_entity_globs, columns, encoder) + i_domains = _domain_matcher(self._included_domains, columns, encoder) + i_entities = _entity_matcher(self._included_entities, columns, encoder) + i_entity_globs = _globs_to_like(self._included_entity_globs, columns, encoder) includes = [i_domains, i_entities, i_entity_globs] - e_domains = _domain_matcher(self.excluded_domains, columns, encoder) - e_entities = _entity_matcher(self.excluded_entities, columns, encoder) - e_entity_globs = _globs_to_like(self.excluded_entity_globs, columns, encoder) + e_domains = _domain_matcher(self._excluded_domains, columns, encoder) + e_entities = _entity_matcher(self._excluded_entities, columns, encoder) + e_entity_globs = _globs_to_like(self._excluded_entity_globs, columns, encoder) excludes = [e_domains, e_entities, e_entity_globs] have_exclude = self._have_exclude @@ -173,7 +184,7 @@ class Filters: # - Otherwise, entity matches glob exclude: exclude # - Otherwise, entity matches domain include: include # - Otherwise: exclude - if self.included_domains or self.included_entity_globs: + if self._included_domains or self._included_entity_globs: return or_( i_entities, # https://github.com/sqlalchemy/sqlalchemy/issues/9190 @@ -187,7 +198,7 @@ class Filters: # - Otherwise, entity matches glob exclude: exclude # - Otherwise, entity matches domain exclude: exclude # - Otherwise: include - if self.excluded_domains or self.excluded_entity_globs: + if self._excluded_domains or self._excluded_entity_globs: return (not_(or_(*excludes)) | i_entities).self_group() # type: ignore[no-any-return, no-untyped-call] # Case 6 - No Domain and/or glob includes or excludes diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index a5c3919505ec..8b46bd976028 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -545,16 +545,15 @@ def test_get_significant_states_only(hass_history) -> None: def check_significant_states(hass, zero, four, states, config): """Check if significant states are retrieved.""" - filters = history.Filters() - exclude = config[history.DOMAIN].get(CONF_EXCLUDE) - if exclude: - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - include = config[history.DOMAIN].get(CONF_INCLUDE) - if include: - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - + domain_config = config[history.DOMAIN] + exclude = domain_config.get(CONF_EXCLUDE, {}) + include = domain_config.get(CONF_INCLUDE, {}) + filters = history.Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + ) hist = get_significant_states(hass, zero, four, filters=filters) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index a300f58b96af..7668d6794d9a 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -600,16 +600,15 @@ def test_get_significant_states_only(legacy_hass_history) -> None: def check_significant_states(hass, zero, four, states, config): """Check if significant states are retrieved.""" - filters = history.Filters() - exclude = config[history.DOMAIN].get(CONF_EXCLUDE) - if exclude: - filters.excluded_entities = exclude.get(CONF_ENTITIES, []) - filters.excluded_domains = exclude.get(CONF_DOMAINS, []) - include = config[history.DOMAIN].get(CONF_INCLUDE) - if include: - filters.included_entities = include.get(CONF_ENTITIES, []) - filters.included_domains = include.get(CONF_DOMAINS, []) - + domain_config = config[history.DOMAIN] + exclude = domain_config.get(CONF_EXCLUDE, {}) + include = domain_config.get(CONF_INCLUDE, {}) + filters = history.Filters( + excluded_entities=exclude.get(CONF_ENTITIES, []), + excluded_domains=exclude.get(CONF_DOMAINS, []), + included_entities=include.get(CONF_ENTITIES, []), + included_domains=include.get(CONF_DOMAINS, []), + ) hist = get_significant_states(hass, zero, four, filters=filters) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) From e22618a555f9d28522b355211a2f53a2bf436b43 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Mar 2023 22:56:51 +0200 Subject: [PATCH 0914/1058] Write protect entity options (#90185) --- homeassistant/components/camera/prefs.py | 3 +- homeassistant/components/sensor/__init__.py | 2 +- homeassistant/helpers/entity_registry.py | 33 +++++++++++++++++---- tests/helpers/test_entity_registry.py | 10 +++++++ tests/syrupy.py | 1 + 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 28e4e1eeacbd..160f896c86ca 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -1,6 +1,7 @@ """Preference management for camera component.""" from __future__ import annotations +from collections.abc import Mapping from dataclasses import asdict, dataclass from typing import Final, cast @@ -89,7 +90,7 @@ class CameraPreferences: # Get preload stream setting from prefs # Get orientation setting from entity registry reg_entry = er.async_get(self._hass).async_get(entity_id) - er_prefs = reg_entry.options.get(DOMAIN, {}) if reg_entry else {} + er_prefs: Mapping = reg_entry.options.get(DOMAIN, {}) if reg_entry else {} preload_prefs = await self._store.async_load() or {} settings = DynamicStreamSettings( preload_stream=cast( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 1812f41693d8..4f56be77a944 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -737,7 +737,7 @@ class SensorEntity(Entity): or "suggested_display_precision" not in self.registry_entry.options ): return - sensor_options = self.registry_entry.options.get(DOMAIN, {}) + sensor_options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) if ( "suggested_display_precision" in sensor_options and sensor_options["suggested_display_precision"] == display_precision diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 4c192d916c19..9cb119b81b43 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -12,6 +12,7 @@ from __future__ import annotations from collections import UserDict from collections.abc import Callable, Iterable, Mapping, ValuesView import logging +from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypeVar, cast import attr @@ -111,6 +112,29 @@ DISLAY_DICT_OPTIONAL = ( ) +class _EntityOptions(UserDict[str, MappingProxyType]): + """Container for entity options.""" + + def __init__(self, data: Mapping[str, Mapping] | None) -> None: + """Initialize.""" + super().__init__() + if data is None: + return + self.data = {key: MappingProxyType(val) for key, val in data.items()} + + def __setitem__(self, key: str, entry: Mapping) -> None: + """Add an item.""" + raise NotImplementedError + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + raise NotImplementedError + + def as_dict(self) -> dict[str, dict]: + """Return dictionary version.""" + return {key: dict(val) for key, val in self.data.items()} + + @attr.s(slots=True, frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -132,10 +156,7 @@ class RegistryEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) - options: EntityOptionsType = attr.ib( - default=None, - converter=attr.converters.default_if_none(factory=dict), # type: ignore[misc] - ) + options: _EntityOptions = attr.ib(default=None, converter=_EntityOptions) # As set by integration original_device_class: str | None = attr.ib(default=None) original_icon: str | None = attr.ib(default=None) @@ -930,7 +951,7 @@ class EntityRegistry: If the domain options are set to None, they will be removed. """ old = self.entities[entity_id] - new_options = { + new_options: dict[str, Mapping] = { key: value for key, value in old.options.items() if key != domain } if options is not None: @@ -1010,7 +1031,7 @@ class EntityRegistry: "id": entry.id, "has_entity_name": entry.has_entity_name, "name": entry.name, - "options": entry.options, + "options": entry.options.as_dict(), "original_device_class": entry.original_device_class, "original_icon": entry.original_icon, "original_name": entry.original_name, diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 79d6de329043..e3b91c46e184 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -747,6 +747,16 @@ async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None assert entry.options == {} assert new_entry_1.options == {"light": {"minimum_brightness": 20}} + # Test it's not possible to modify the options + with pytest.raises(NotImplementedError): + new_entry_1.options["blah"] = {} + with pytest.raises(NotImplementedError): + new_entry_1.options["light"] = {} + with pytest.raises(TypeError): + new_entry_1.options["light"]["blah"] = 123 + with pytest.raises(TypeError): + new_entry_1.options["light"]["minimum_brightness"] = 123 + entity_registry.async_update_entity_options( entry.entity_id, "light", {"minimum_brightness": 30} ) diff --git a/tests/syrupy.py b/tests/syrupy.py index f18c11bf5d56..af34cb628fc1 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -170,6 +170,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): "config_entry_id": ANY, "device_id": ANY, "id": ANY, + "options": data.options.as_dict(), } ) serialized.pop("_partial_repr") From f60e9c71a2135d2b18f433a194a733acdb7f1908 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 11:22:41 -1000 Subject: [PATCH 0915/1058] Make bootstrap cancelation safe (#90420) --- homeassistant/bootstrap.py | 19 +++++++++---------- tests/test_bootstrap.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 9ba4e99a0823..eb3aa3a22399 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -515,16 +515,15 @@ async def async_setup_multi_components( ) for domain in domains } - await asyncio.wait(futures.values()) - errors = [domain for domain in domains if futures[domain].exception()] - for domain in errors: - exception = futures[domain].exception() - assert exception is not None - _LOGGER.error( - "Error setting up integration %s - received exception", - domain, - exc_info=(type(exception), exception, exception.__traceback__), - ) + results = await asyncio.gather(*futures.values(), return_exceptions=True) + for idx, domain in enumerate(futures): + result = results[idx] + if isinstance(result, BaseException): + _LOGGER.error( + "Error setting up integration %s - received exception", + domain, + exc_info=(type(result), result, result.__traceback__), + ) async def _async_set_up_integrations( diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 9f02d6394e08..cd0d7ef069eb 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -806,3 +806,26 @@ async def test_warning_logged_on_wrap_up_timeout( await hass.async_block_till_done() assert "Setup timed out for bootstrap - moving forward" in caplog.text + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_is_cancellation_safe( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test cancellation during async_setup_component does not cancel bootstrap.""" + with patch.object( + bootstrap, "async_setup_component", side_effect=asyncio.CancelledError + ): + await bootstrap._async_set_up_integrations(hass, {"cancel_integration": {}}) + await hass.async_block_till_done() + + assert "Error setting up integration cancel_integration" in caplog.text + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_empty_integrations( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test setting up an empty integrations does not raise.""" + await bootstrap.async_setup_multi_components(hass, set(), {}) + await hass.async_block_till_done() From 9ae02362083077a7122e821edc4c0f96dfc05532 Mon Sep 17 00:00:00 2001 From: mletenay Date: Tue, 28 Mar 2023 23:31:14 +0200 Subject: [PATCH 0916/1058] Add goodwe sensors for apparent/reactive pwr (#87940) --- homeassistant/components/goodwe/sensor.py | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index b4adf97c3e7d..d76d62028329 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -19,13 +19,16 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, EntityCategory, + UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, UnitOfPower, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -111,6 +114,20 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { value=lambda coordinator, sensor: coordinator.total_sensor_value(sensor), available=lambda coordinator: coordinator.data is not None, ), + "VA": GoodweSensorEntityDescription( + key="VA", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + entity_registry_enabled_default=False, + ), + "var": GoodweSensorEntityDescription( + key="var", + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + entity_registry_enabled_default=False, + ), "C": GoodweSensorEntityDescription( key="C", device_class=SensorDeviceClass.TEMPERATURE, @@ -123,6 +140,13 @@ _DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, ), + "h": GoodweSensorEntityDescription( + key="h", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_registry_enabled_default=False, + ), "%": GoodweSensorEntityDescription( key="%", state_class=SensorStateClass.MEASUREMENT, From 9dc936f8b9a443a9052210c0b6d6e89369193f48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 12:02:35 -1000 Subject: [PATCH 0917/1058] Add sqlalchemy LRUs to the profiler lru service (#90428) --- homeassistant/components/profiler/__init__.py | 12 +++++++++++- tests/components/profiler/test_init.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index b838f67d02e3..27e302f47c4f 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -35,6 +35,7 @@ SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" _LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ +_SQLALCHEMY_LRU_OBJECT = "LRUCache" _KNOWN_LRU_CLASSES = ( "EventDataManager", @@ -67,7 +68,9 @@ LOG_INTERVAL_SUB = "log_interval_subscription" _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up Profiler from a config entry.""" lock = asyncio.Lock() domain_data = hass.data[DOMAIN] = {} @@ -176,6 +179,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: maybe_lru.get_stats(), ) + for lru in objgraph.by_type(_SQLALCHEMY_LRU_OBJECT): + if (data := getattr(lru, "_data", None)) and isinstance(data, dict): + for key, value in dict(data).items(): + _LOGGER.critical( + "Cache data for sqlalchemy LRUCache %s: %s: %s", lru, key, value + ) + persistent_notification.create( hass, ( diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index af642c779e1c..9466660dca40 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -11,6 +11,7 @@ import pytest from homeassistant.components.profiler import ( _LRU_CACHE_WRAPPER_OBJECT, + _SQLALCHEMY_LRU_OBJECT, CONF_SECONDS, SERVICE_DUMP_LOG_OBJECTS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, @@ -254,9 +255,17 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) domain_data = DomainData() assert hass.services.has_service(DOMAIN, SERVICE_LRU_STATS) + class LRUCache: + def __init__(self): + self._data = {"sqlalchemy_test": 1} + + sqlalchemy_lru_cache = LRUCache() + def _mock_by_type(type_): if type_ == _LRU_CACHE_WRAPPER_OBJECT: return [_dummy_test_lru_stats] + if type_ == _SQLALCHEMY_LRU_OBJECT: + return [sqlalchemy_lru_cache] return [domain_data] with patch("objgraph.by_type", side_effect=_mock_by_type): @@ -266,3 +275,4 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) assert "(0, 0)" in caplog.text assert "_dummy_test_lru_stats" in caplog.text assert "CacheInfo" in caplog.text + assert "sqlalchemy_test" in caplog.text From ee2101ef38870149ce1b61e46596259c847fdd35 Mon Sep 17 00:00:00 2001 From: MattWestb <49618193+MattWestb@users.noreply.github.com> Date: Wed, 29 Mar 2023 01:33:06 +0200 Subject: [PATCH 0918/1058] Add binding of IKEA Matter Switch cluster in ZHA (#89623) * Adding binding of IKEA Matter Switch cluster IKEA Symfonisk Gen 2 is using Matter ZCL Switch command but on manufacture cluster then its not supported in ZVL R8 that need being bond for sending the commands to the coordinator. * Update manufacturerspecific.py * Update manufacturerspecific.py Delete not needed function `@registries.BINDABLE_CLUSTERS.register(0xFC80)` --- .../components/zha/core/channels/manufacturerspecific.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 85a478b0d4dd..107df3a2da22 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -324,3 +324,11 @@ class IkeaAirPurifierChannel(ZigbeeChannel): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) + + +@registries.CHANNEL_ONLY_CLUSTERS.register(0xFC80) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFC80) +class IkeaRemote(ZigbeeChannel): + """Ikea Matter remote channel.""" + + REPORT_CONFIG = () From 4f05246654625787f153b23166e315970329802c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 13:54:33 -1000 Subject: [PATCH 0919/1058] Bump onvif-zeep-async to 1.2.3 (#90382) --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 4b998bdd6cd0..ef4497fa284e 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==1.2.2", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==1.2.3", "WSDiscovery==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c5dec6d8c6a..ca51ee1b7ff1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1260,7 +1260,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.2.2 +onvif-zeep-async==1.2.3 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ab64c066cab..6bfaf522c6c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.2.2 +onvif-zeep-async==1.2.3 # homeassistant.components.opengarage open-garage==0.2.0 From 86600350275c695bb5c867ea09fde6f13d2abec0 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 28 Mar 2023 20:59:26 -0400 Subject: [PATCH 0920/1058] Bump ZHA quirks to 0.0.95 (#90435) --- 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 3061d867b657..d82fe5ed0f86 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "bellows==0.34.10", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.94", + "zha-quirks==0.0.95", "zigpy-deconz==0.19.2", "zigpy==0.53.2", "zigpy-xbee==0.16.2", diff --git a/requirements_all.txt b/requirements_all.txt index ca51ee1b7ff1..49ac9171eca4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2701,7 +2701,7 @@ zeroconf==0.47.4 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.94 +zha-quirks==0.0.95 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6bfaf522c6c1..4213338da8a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1935,7 +1935,7 @@ zeroconf==0.47.4 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.94 +zha-quirks==0.0.95 # homeassistant.components.zha zigpy-deconz==0.19.2 From 12f49006cfc699120f874d0b97e9357ba9302e64 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Mar 2023 03:12:21 +0200 Subject: [PATCH 0921/1058] Add Aqara E1 thermostat entities to ZHA (#90158) * Add Aqara E1 thermostat entities (WIP) * Remove calibrate button for now * Add diagnostic entity category to calibrated + external sensor * Add multiplier for ZHA config number/away preset temp * Set default multiplier correctly * Add and use `CONFIG_DIAGNOSTIC_MATCH` for diagnostic entities --- homeassistant/components/zha/binary_sensor.py | 41 ++++++++++++++++++- .../zha/core/channels/manufacturerspecific.py | 14 +++++++ homeassistant/components/zha/number.py | 24 +++++++++-- homeassistant/components/zha/select.py | 17 ++++++++ homeassistant/components/zha/switch.py | 29 +++++++++++++ 5 files changed, 121 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 9c2fb49de61a..fc49026d886f 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,6 +39,9 @@ CLASS_MAPPING = { STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.BINARY_SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BINARY_SENSOR) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + ZHA_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR +) async def async_setup_entry( @@ -201,3 +204,39 @@ class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): SENSOR_ATTR = "consumer_connected" _attr_name: str = "Consumer connected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatWindowOpen(BinarySensor, id_suffix="window_open"): + """ZHA Aqara thermostat window open binary sensor.""" + + SENSOR_ATTR = "window_open" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW + _attr_name: str = "Window open" + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatValveAlarm(BinarySensor, id_suffix="valve_alarm"): + """ZHA Aqara thermostat valve alarm binary sensor.""" + + SENSOR_ATTR = "valve_alarm" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM + _attr_name: str = "Valve alarm" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatCalibrated(BinarySensor, id_suffix="calibrated"): + """ZHA Aqara thermostat calibrated binary sensor.""" + + SENSOR_ATTR = "calibrated" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_name: str = "Calibrated" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): + """ZHA Aqara thermostat external sensor binary sensor.""" + + SENSOR_ATTR = "sensor" + _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC + _attr_name: str = "External sensor" diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 107df3a2da22..629e618db0aa 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -138,6 +138,20 @@ class OppleRemote(ZigbeeChannel): "serving_size": True, "portion_weight": True, } + elif self.cluster.endpoint.model == "lumi.airrtc.agl001": + self.ZCL_INIT_ATTRS = { + "system_mode": True, + "preset": True, + "window_detection": True, + "valve_detection": True, + "valve_alarm": True, + "child_lock": True, + "away_preset_temperature": True, + "window_open": True, + "calibrated": True, + "schedule": True, + "sensor": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 334b72dccc58..d0ec62eaf618 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -11,7 +11,7 @@ from zigpy.zcl.foundation import Status from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform, UnitOfMass +from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -375,6 +375,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG _attr_native_step: float = 1.0 + _attr_multiplier: float = 1 _zcl_attribute: str @classmethod @@ -417,13 +418,13 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): @property def native_value(self) -> float: """Return the current value.""" - return self._channel.cluster.get(self._zcl_attribute) + return self._channel.cluster.get(self._zcl_attribute) * self._attr_multiplier async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" try: res = await self._channel.cluster.write_attributes( - {self._zcl_attribute: int(value)} + {self._zcl_attribute: int(value / self._attr_multiplier)} ) except zigpy.exceptions.ZigbeeException as ex: self.error("Could not set value: %s", ex) @@ -861,3 +862,20 @@ class AqaraPetFeederPortionWeight( _attr_mode: NumberMode = NumberMode.BOX _attr_native_unit_of_measurement: str = UnitOfMass.GRAMS _attr_icon: str = "mdi:weight-gram" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatAwayTemp( + ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" +): + """Aqara away preset temperature configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value: float = 5 + _attr_native_max_value: float = 30 + _attr_multiplier: float = 0.01 + _zcl_attribute: str = "away_preset_temperature" + _attr_name: str = "Away preset temperature" + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index b4cbce554033..605c7d507c66 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -503,3 +503,20 @@ class AqaraPetFeederMode(ZCLEnumSelectEntity, id_suffix="feeding_mode"): _enum = AqaraFeedingMode _attr_name = "Mode" _attr_icon: str = "mdi:wrench-clock" + + +class AqaraThermostatPresetMode(types.enum8): + """Thermostat preset mode.""" + + Manual = 0x00 + Auto = 0x01 + Away = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatPreset(ZCLEnumSelectEntity, id_suffix="preset"): + """Representation of an Aqara thermostat preset configuration entity.""" + + _select_attr = "preset" + _enum = AqaraThermostatPresetMode + _attr_name = "Preset" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 09cebc8f4ced..9323e3ddc8da 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -477,3 +477,32 @@ class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatWindowDetection( + ZHASwitchConfigurationEntity, id_suffix="window_detection" +): + """Representation of an Aqara thermostat window detection configuration entity.""" + + _zcl_attribute: str = "window_detection" + _attr_name = "Window detection" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatValveDetection( + ZHASwitchConfigurationEntity, id_suffix="valve_detection" +): + """Representation of an Aqara thermostat valve detection configuration entity.""" + + _zcl_attribute: str = "valve_detection" + _attr_name = "Valve detection" + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.airrtc.agl001"}) +class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): + """Representation of an Aqara thermostat child lock configuration entity.""" + + _zcl_attribute: str = "child_lock" + _attr_name = "Child lock" + _attr_icon: str = "mdi:account-lock" From 47a2598b6652b46e31b1a9a55f7614a83ed49b24 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Mar 2023 03:30:56 +0200 Subject: [PATCH 0922/1058] Add Aqara smoke sensor entities to ZHA (#90159) * Add Aqara smoke sensor entities (WIP) * Update smoke sensor entities (WIP) * Drop two init attributes * Move self-test button * Remove self-test switch, add icons * Add smoke sensor dbm entity * Also add SMOKE device class to linkage alarm Note: Enable "Linkage alarm" for this --- homeassistant/components/zha/binary_sensor.py | 9 ++++ homeassistant/components/zha/button.py | 12 +++++ .../zha/core/channels/manufacturerspecific.py | 9 ++++ homeassistant/components/zha/sensor.py | 12 +++++ homeassistant/components/zha/switch.py | 50 +++++++++++++++++++ 5 files changed, 92 insertions(+) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index fc49026d886f..b277b3fe2671 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -240,3 +240,12 @@ class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): SENSOR_ATTR = "sensor" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_name: str = "External sensor" + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +class AqaraLinkageAlarmState(BinarySensor, id_suffix="linkage_alarm_state"): + """ZHA Aqara linkage alarm state binary sensor.""" + + SENSOR_ATTR = "linkage_alarm_state" + _attr_name: str = "Linkage alarm state" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 14547216dcbc..b3ff3f5aedd9 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -184,3 +184,15 @@ class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): _attribute_name = "feeding" _attr_name = "Feed" _attribute_value = 1 + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraSelfTestButton(ZHAAttributeButton, id_suffix="self_test"): + """Defines a ZHA self-test button for Aqara smoke sensors.""" + + _attribute_name = "self_test" + _attr_name = "Self-test" + _attribute_value = 1 + _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 629e618db0aa..e312f398b543 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -152,6 +152,15 @@ class OppleRemote(ZigbeeChannel): "schedule": True, "sensor": True, } + elif self.cluster.endpoint.model == "lumi.sensor_smoke.acn03": + self.ZCL_INIT_ATTRS = { + "buzzer_manual_mute": True, + "smoke_density": True, + "heartbeat_indicator": True, + "buzzer_manual_alarm": True, + "buzzer": True, + "linkage_alarm": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 78ce47c7e571..a7a090b13afe 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -955,3 +955,15 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): _attr_native_unit_of_measurement = UnitOfMass.GRAMS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_icon: str = "mdi:weight-gram" + + +@MULTI_MATCH(channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +class AqaraSmokeDensityDbm(Sensor, id_suffix="smoke_density_dbm"): + """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" + + SENSOR_ATTR = "smoke_density_dbm" + _attr_name: str = "Smoke density" + _attr_native_unit_of_measurement = "dB/m" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_icon: str = "mdi:google-circles-communities" + _attr_suggested_display_precision: int = 3 diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 9323e3ddc8da..f0e36750798b 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -506,3 +506,53 @@ class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lo _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraHeartbeatIndicator( + ZHASwitchConfigurationEntity, id_suffix="heartbeat_indicator" +): + """Representation of a heartbeat indicator configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "heartbeat_indicator" + _attr_name = "Heartbeat indicator" + _attr_icon: str = "mdi:heart-flash" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraLinkageAlarm(ZHASwitchConfigurationEntity, id_suffix="linkage_alarm"): + """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "linkage_alarm" + _attr_name = "Linkage alarm" + _attr_icon: str = "mdi:shield-link-variant" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualMute( + ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_mute" +): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "buzzer_manual_mute" + _attr_name = "Buzzer manual mute" + _attr_icon: str = "mdi:volume-off" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} +) +class AqaraBuzzerManualAlarm( + ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_alarm" +): + """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + + _zcl_attribute: str = "buzzer_manual_alarm" + _attr_name = "Buzzer manual alarm" + _attr_icon: str = "mdi:bullhorn" From e3cad8baac96ec60cfc1d30fbf6d9bdd03ffe12b Mon Sep 17 00:00:00 2001 From: Thijs W Date: Wed, 29 Mar 2023 04:06:21 +0200 Subject: [PATCH 0923/1058] Migrate ssdp to config_flow for frontier_silicon (#89496) Co-authored-by: J. Nick Koston --- .../components/discovery/__init__.py | 1 - .../frontier_silicon/config_flow.py | 97 ++++++++++-- .../components/frontier_silicon/const.py | 3 + .../components/frontier_silicon/manifest.json | 3 +- .../frontier_silicon/media_player.py | 14 -- homeassistant/generated/ssdp.py | 5 + .../frontier_silicon/test_config_flow.py | 143 ++++++++++++++++-- 7 files changed, 221 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 0ffd6fe49efe..204992b48fa9 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -60,7 +60,6 @@ class ServiceDetails(NamedTuple): SERVICE_HANDLERS = { SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), "yamaha": ServiceDetails("media_player", "yamaha"), - "frontier_silicon": ServiceDetails("media_player", "frontier_silicon"), "openhome": ServiceDetails("media_player", "openhome"), "bluesound": ServiceDetails("media_player", "bluesound"), } diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a3fbdb52c1c6..a054bd2b30e4 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -3,15 +3,24 @@ from __future__ import annotations import logging from typing import Any +from urllib.parse import urlparse from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from .const import CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, DOMAIN +from .const import ( + CONF_PIN, + CONF_WEBFSAPI_URL, + DEFAULT_PIN, + DEFAULT_PORT, + DOMAIN, + SSDP_ATTR_SPEAKER_NAME, +) _LOGGER = logging.getLogger(__name__) @@ -32,11 +41,17 @@ STEP_DEVICE_CONFIG_DATA_SCHEMA = vol.Schema( ) +def hostname_from_url(url: str) -> str: + """Return the hostname from a url.""" + return str(urlparse(url).hostname) + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Frontier Silicon Media Player.""" VERSION = 1 + _name: str _webfsapi_url: str async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: @@ -101,6 +116,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: + """Process entity discovered via SSDP.""" + + device_url = discovery_info.ssdp_location + if device_url is None: + return self.async_abort(reason="cannot_connect") + + device_hostname = hostname_from_url(device_url) + for entry in self._async_current_entries(include_ignore=False): + if device_hostname == hostname_from_url(entry.data[CONF_WEBFSAPI_URL]): + return self.async_abort(reason="already_configured") + + speaker_name = discovery_info.ssdp_headers.get(SSDP_ATTR_SPEAKER_NAME) + self.context["title_placeholders"] = {"name": speaker_name} + + try: + self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) + except FSConnectionError: + return self.async_abort(reason="cannot_connect") + except Exception as exception: # pylint: disable=broad-except + _LOGGER.debug(exception) + return self.async_abort(reason="unknown") + + try: + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) + + unique_id = await afsapi.get_radio_id() + except InvalidPinException: + return self.async_abort(reason="invalid_auth") + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_WEBFSAPI_URL: self._webfsapi_url}, reload_on_update=True + ) + + self._name = await afsapi.get_friendly_name() + + return await self.async_step_confirm() + async def _async_step_device_config_if_needed(self) -> FlowResult: """Most users will not have changed the default PIN on their radio. @@ -111,21 +166,29 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # try to login with default pin afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) - name = await afsapi.get_friendly_name() + self._name = await afsapi.get_friendly_name() except InvalidPinException: # Ask for a PIN return await self.async_step_device_config() - self.context["title_placeholders"] = {"name": name} + self.context["title_placeholders"] = {"name": self._name} unique_id = await afsapi.get_radio_id() await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: DEFAULT_PIN}, - ) + return await self._async_create_entry() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Allow the user to confirm adding the device. Used when the default PIN could successfully be used.""" + + if user_input is not None: + return await self._async_create_entry() + + self._set_confirm_only() + return self.async_show_form(step_id="confirm") async def async_step_device_config( self, user_input: dict[str, Any] | None = None @@ -145,7 +208,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: afsapi = AFSAPI(self._webfsapi_url, user_input[CONF_PIN]) - name = await afsapi.get_friendly_name() + self._name = await afsapi.get_friendly_name() except FSConnectionError: errors["base"] = "cannot_connect" @@ -156,15 +219,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: unique_id = await afsapi.get_radio_id() - await self.async_set_unique_id(unique_id) + await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=name, - data={ - CONF_WEBFSAPI_URL: self._webfsapi_url, - CONF_PIN: user_input[CONF_PIN], - }, - ) + return await self._async_create_entry(user_input[CONF_PIN]) data_schema = self.add_suggested_values_to_schema( STEP_DEVICE_CONFIG_DATA_SCHEMA, user_input @@ -174,3 +231,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) + + async def _async_create_entry(self, pin: str | None = None): + """Create the entry.""" + + return self.async_create_entry( + title=self._name, + data={CONF_WEBFSAPI_URL: self._webfsapi_url, CONF_PIN: pin or DEFAULT_PIN}, + ) diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 9206db89166b..34201fe8f4a3 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -4,6 +4,9 @@ DOMAIN = "frontier_silicon" CONF_WEBFSAPI_URL = "webfsapi_url" CONF_PIN = "pin" +SSDP_ST = "urn:schemas-frontier-silicon-com:undok:fsapi:1" +SSDP_ATTR_SPEAKER_NAME = "SPEAKER-NAME" + DEFAULT_PIN = "1234" DEFAULT_PORT = 80 diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 62e7e6170345..9cc928e6f883 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "iot_class": "local_polling", - "requirements": ["afsapi==0.2.7"] + "requirements": ["afsapi==0.2.7"], + "ssdp": [{ "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" }] } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 7f73823239c9..54c17429b56e 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -54,21 +54,7 @@ async def async_setup_platform( """Set up the Frontier Silicon platform. YAML is deprecated, and imported automatically. - SSDP discovery is temporarily retained - to be refactor subsequently. """ - if discovery_info is not None: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint( - discovery_info["ssdp_description"] - ) - afsapi = AFSAPI(webfsapi_url, DEFAULT_PIN) - - name = await afsapi.get_friendly_name() - async_add_entities( - [AFSAPIDevice(name, afsapi)], - True, - ) - - return ir.async_create_issue( hass, diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index e5e83d5eae9f..3f26ec8fa78b 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -130,6 +130,11 @@ SSDP = { "st": "urn:schemas-upnp-org:device:fritzbox:1", }, ], + "frontier_silicon": [ + { + "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1", + }, + ], "harmony": [ { "deviceType": "urn:myharmony-com:device:harmony:1", diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index 6a61f0b61855..612058af0a1e 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -5,7 +5,12 @@ from afsapi import ConnectionError, InvalidPinException import pytest from homeassistant import config_entries -from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN +from homeassistant.components import ssdp +from homeassistant.components.frontier_silicon.const import ( + CONF_WEBFSAPI_URL, + DEFAULT_PIN, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -15,6 +20,23 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") +MOCK_DISCOVERY = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", + ssdp_st="mock_st", + ssdp_location="http://1.1.1.1/device", + upnp={"SPEAKER-NAME": "Speaker Name"}, +) + +INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_udn="uuid:3dcc7100-f76c-11dd-87af-00226124ca30", + ssdp_st="mock_st", + ssdp_location=None, + upnp={"SPEAKER-NAME": "Speaker Name"}, +) + + async def test_import_success(hass: HomeAssistant) -> None: """Test successful import.""" @@ -49,7 +71,7 @@ async def test_import_webfsapi_endpoint_failures( ) -> None: """Test various failure of get_webfsapi_endpoint.""" with patch( - "afsapi.AFSAPI.get_webfsapi_endpoint", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", side_effect=webfsapi_endpoint_error, ): result = await hass.config_entries.flow.async_init( @@ -80,7 +102,7 @@ async def test_import_radio_id_failures( ) -> None: """Test various failure of get_radio_id.""" with patch( - "afsapi.AFSAPI.get_radio_id", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", side_effect=radio_id_error, ): result = await hass.config_entries.flow.async_init( @@ -157,7 +179,7 @@ async def test_form_nondefault_pin( assert result["errors"] == {} with patch( - "afsapi.AFSAPI.get_friendly_name", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=InvalidPinException, ): result2 = await hass.config_entries.flow.async_configure( @@ -179,8 +201,8 @@ async def test_form_nondefault_pin( assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Name of the device" assert result3["data"] == { - "webfsapi_url": "http://1.1.1.1:80/webfsapi", - "pin": "4321", + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "4321", } mock_setup_entry.assert_called_once() @@ -208,7 +230,7 @@ async def test_form_nondefault_pin_invalid( assert result["errors"] == {} with patch( - "afsapi.AFSAPI.get_friendly_name", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=InvalidPinException, ): result2 = await hass.config_entries.flow.async_configure( @@ -222,7 +244,7 @@ async def test_form_nondefault_pin_invalid( assert result2["errors"] is None with patch( - "afsapi.AFSAPI.get_friendly_name", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=friendly_name_error, ): result3 = await hass.config_entries.flow.async_configure( @@ -244,8 +266,8 @@ async def test_form_nondefault_pin_invalid( assert result4["type"] == FlowResultType.CREATE_ENTRY assert result4["title"] == "Name of the device" assert result4["data"] == { - "webfsapi_url": "http://1.1.1.1:80/webfsapi", - "pin": "4321", + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "4321", } mock_setup_entry.assert_called_once() @@ -272,7 +294,7 @@ async def test_invalid_device_url( assert result["errors"] == {} with patch( - "afsapi.AFSAPI.get_webfsapi_endpoint", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", side_effect=webfsapi_endpoint_error, ): result2 = await hass.config_entries.flow.async_configure( @@ -294,7 +316,102 @@ async def test_invalid_device_url( assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Name of the device" assert result3["data"] == { - "webfsapi_url": "http://1.1.1.1:80/webfsapi", - "pin": "1234", + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: "1234", } mock_setup_entry.assert_called_once() + + +async def test_ssdp(hass: HomeAssistant, mock_setup_entry: MockConfigEntry) -> None: + """Test a device being discovered.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Name of the device" + assert result2["data"] == { + CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", + CONF_PIN: DEFAULT_PIN, + } + mock_setup_entry.assert_called_once() + + +async def test_ssdp_invalid_location(hass: HomeAssistant) -> None: + """Test a device being discovered.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=INVALID_MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_ssdp_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test an already known device being discovered.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("webfsapi_endpoint_error", "result_error"), + [(ValueError, "unknown"), (ConnectionError, "cannot_connect")], +) +async def test_ssdp_fail( + hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_error: str +) -> None: + """Test a device being discovered but failing to reply.""" + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", + side_effect=webfsapi_endpoint_error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == result_error + + +async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None: + """Test a device being discovered.""" + + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + side_effect=InvalidPinException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_auth" From 2c7c8ccbfe55a70fd5f6e5722c2f3902b1d9fe7e Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 28 Mar 2023 20:36:26 -0600 Subject: [PATCH 0924/1058] Fix bluetooth polling recovered log missing argument (#90436) --- homeassistant/components/bluetooth/active_update_coordinator.py | 2 +- homeassistant/components/bluetooth/active_update_processor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index d5cf65d8724a..6d4e67119d5a 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -143,7 +143,7 @@ class ActiveBluetoothDataUpdateCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True self._async_handle_bluetooth_poll() diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index aabc27ff14ea..b450c6122503 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -136,7 +136,7 @@ class ActiveBluetoothProcessorCoordinator( self._last_poll = monotonic_time_coarse() if not self.last_poll_successful: - self.logger.debug("%s: Polling recovered") + self.logger.debug("%s: Polling recovered", self.address) self.last_poll_successful = True for processor in self._processors: From ce28bfe5b21a2364727f3722f200546461ac0f77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:01:11 -1000 Subject: [PATCH 0925/1058] Remove unused types argument in statistics query generation (#90431) * Remove unused types argument in statistics query generation * update test --- homeassistant/components/recorder/statistics.py | 3 +-- tests/components/recorder/test_statistics.py | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8025616d2467..9f78b0534bab 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1034,7 +1034,6 @@ def _generate_statistics_during_period_stmt( end_time: datetime | None, metadata_ids: list[int] | None, table: type[StatisticsBase], - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. @@ -1535,7 +1534,7 @@ def _statistics_during_period_with_session( if "sum" in types: columns = columns.add_columns(table.sum) stmt = _generate_statistics_during_period_stmt( - columns, start_time, end_time, metadata_ids, table, types + columns, start_time, end_time, metadata_ids, table ) stats = cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index ebad039ca454..ff429794315b 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1246,11 +1246,11 @@ def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" columns = select(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start_ts) stmt = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm ) cache_key_1 = stmt._generate_cache_key() stmt2 = _generate_statistics_during_period_stmt( - columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm, {} + columns, dt_util.utcnow(), dt_util.utcnow(), [0], StatisticsShortTerm ) cache_key_2 = stmt2._generate_cache_key() assert cache_key_1 == cache_key_2 @@ -1266,7 +1266,6 @@ def test_cache_key_for_generate_statistics_during_period_stmt() -> None: dt_util.utcnow(), [0], StatisticsShortTerm, - {"max", "mean"}, ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 From 403dffc12d40aae08f8f6e82a12eb79a9b71412b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:28:24 -1000 Subject: [PATCH 0926/1058] Reduce cache key size for queries that only need single columns (#90430) * Reduce cache key size for queries that only need single columns These queries only cared about a single row but would select the whole set of columns from the orm object * wrap it --- homeassistant/components/recorder/migration.py | 6 +++++- homeassistant/components/recorder/statistics.py | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 0eee065a0ca8..4be01327654c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -143,7 +143,11 @@ def raise_if_exception_missing_str(ex: Exception, match_substrs: Iterable[str]) def _get_schema_version(session: Session) -> int | None: """Get the schema version.""" - res = session.query(SchemaChanges).order_by(SchemaChanges.change_id.desc()).first() + res = ( + session.query(SchemaChanges.schema_version) + .order_by(SchemaChanges.change_id.desc()) + .first() + ) return getattr(res, "schema_version", None) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 9f78b0534bab..0122ba4464b8 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -480,6 +480,11 @@ def compile_statistics(instance: Recorder, start: datetime, fire_events: bool) - return True +def _get_first_id_stmt(start: datetime) -> StatementLambdaElement: + """Return a statement that returns the first run_id at start.""" + return lambda_stmt(lambda: select(StatisticsRuns.run_id).filter_by(start=start)) + + def _compile_statistics( instance: Recorder, session: Session, start: datetime, fire_events: bool ) -> set[str]: @@ -496,7 +501,7 @@ def _compile_statistics( modified_statistic_ids: set[str] = set() # Return if we already have 5-minute statistics for the requested period - if session.query(StatisticsRuns).filter_by(start=start).first(): + if execute_stmt_lambda_element(session, _get_first_id_stmt(start)): _LOGGER.debug("Statistics already compiled for %s-%s", start, end) return modified_statistic_ids From 885be98f8fcc279840cbdb6d5850231d2076e328 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Mar 2023 23:37:43 -0400 Subject: [PATCH 0927/1058] OpenAI to use GPT3.5 (#90423) * OpenAI to use GPT3.5 * Add snapshot --- .../openai_conversation/__init__.py | 42 ++++++---------- .../openai_conversation/config_flow.py | 41 +++++++++++----- .../components/openai_conversation/const.py | 8 +--- .../openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_init.ambr | 34 +++++++++++++ .../openai_conversation/test_config_flow.py | 6 +-- .../openai_conversation/test_init.py | 48 ++++++++----------- 9 files changed, 107 insertions(+), 78 deletions(-) create mode 100644 tests/components/openai_conversation/snapshots/test_init.ambr diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 355b7764b087..3e67d4e27dac 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -16,13 +16,13 @@ from homeassistant.helpers import area_registry as ar, intent, template from homeassistant.util import ulid from .const import ( + CONF_CHAT_MODEL, CONF_MAX_TOKENS, - CONF_MODEL, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_CHAT_MODEL, DEFAULT_MAX_TOKENS, - DEFAULT_MODEL, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_P, @@ -63,7 +63,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): """Initialize the agent.""" self.hass = hass self.entry = entry - self.history: dict[str, str] = {} + self.history: dict[str, list[dict]] = {} @property def attribution(self): @@ -75,14 +75,14 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) -> conversation.ConversationResult: """Process a sentence.""" raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) - model = self.entry.options.get(CONF_MODEL, DEFAULT_MODEL) + model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) max_tokens = self.entry.options.get(CONF_MAX_TOKENS, DEFAULT_MAX_TOKENS) top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) if user_input.conversation_id in self.history: conversation_id = user_input.conversation_id - prompt = self.history[conversation_id] + messages = self.history[conversation_id] else: conversation_id = ulid.ulid() try: @@ -97,25 +97,16 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) + messages = [{"role": "system", "content": prompt}] - user_name = "User" - if ( - user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) - ) - and user.name - ): - user_name = user.name + messages.append({"role": "user", "content": user_input.text}) - prompt += f"\n{user_name}: {user_input.text}\nSmart home: " - - _LOGGER.debug("Prompt for %s: %s", model, prompt) + _LOGGER.debug("Prompt for %s: %s", model, messages) try: - result = await openai.Completion.acreate( - engine=model, - prompt=prompt, + result = await openai.ChatCompletion.acreate( + model=model, + messages=messages, max_tokens=max_tokens, top_p=top_p, temperature=temperature, @@ -132,15 +123,12 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) _LOGGER.debug("Response %s", result) - response = result["choices"][0]["text"].strip() - self.history[conversation_id] = prompt + response - - stripped_response = response - if response.startswith("Smart home:"): - stripped_response = response[11:].strip() + response = result["choices"][0]["message"] + messages.append(response) + self.history[conversation_id] = messages intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(stripped_response) + intent_response.async_set_speech(response["content"]) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 2db5e98a1f40..892d794bcaf1 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -22,13 +22,13 @@ from homeassistant.helpers.selector import ( ) from .const import ( + CONF_CHAT_MODEL, CONF_MAX_TOKENS, - CONF_MODEL, CONF_PROMPT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_CHAT_MODEL, DEFAULT_MAX_TOKENS, - DEFAULT_MODEL, DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_P, @@ -46,7 +46,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( DEFAULT_OPTIONS = types.MappingProxyType( { CONF_PROMPT: DEFAULT_PROMPT, - CONF_MODEL: DEFAULT_MODEL, + CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, CONF_MAX_TOKENS: DEFAULT_MAX_TOKENS, CONF_TOP_P: DEFAULT_TOP_P, CONF_TEMPERATURE: DEFAULT_TEMPERATURE, @@ -131,13 +131,32 @@ def openai_config_option_schema(options: MappingProxyType[str, Any]) -> dict: if not options: options = DEFAULT_OPTIONS return { - vol.Required(CONF_PROMPT, default=options.get(CONF_PROMPT)): TemplateSelector(), - vol.Required(CONF_MODEL, default=options.get(CONF_MODEL)): str, - vol.Required(CONF_MAX_TOKENS, default=options.get(CONF_MAX_TOKENS)): int, - vol.Required(CONF_TOP_P, default=options.get(CONF_TOP_P)): NumberSelector( - NumberSelectorConfig(min=0, max=1, step=0.05) - ), - vol.Required( - CONF_TEMPERATURE, default=options.get(CONF_TEMPERATURE) + vol.Optional( + CONF_PROMPT, + description={"suggested_value": options[CONF_PROMPT]}, + default=DEFAULT_PROMPT, + ): TemplateSelector(), + vol.Optional( + CONF_CHAT_MODEL, + description={ + # New key in HA 2023.4 + "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + }, + default=DEFAULT_CHAT_MODEL, + ): str, + vol.Optional( + CONF_MAX_TOKENS, + description={"suggested_value": options[CONF_MAX_TOKENS]}, + default=DEFAULT_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options[CONF_TOP_P]}, + default=DEFAULT_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options[CONF_TEMPERATURE]}, + default=DEFAULT_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), } diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index ed914efeb6ee..88289eb90b04 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -22,13 +22,9 @@ An overview of the areas and the devices in this smart home: 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. - -Now finish this conversation: - -Smart home: How can I assist? """ -CONF_MODEL = "model" -DEFAULT_MODEL = "text-davinci-003" +CONF_CHAT_MODEL = "chat_model" +DEFAULT_CHAT_MODEL = "gpt-3.5-turbo" CONF_MAX_TOKENS = "max_tokens" DEFAULT_MAX_TOKENS = 150 CONF_TOP_P = "top_p" diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 0e245eb78b5f..88d347355e9e 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==0.26.2"] + "requirements": ["openai==0.27.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 49ac9171eca4..aeb7033d0a6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1269,7 +1269,7 @@ open-garage==0.2.0 open-meteo==0.2.1 # homeassistant.components.openai_conversation -openai==0.26.2 +openai==0.27.2 # homeassistant.components.opencv # opencv-python-headless==4.6.0.66 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4213338da8a0..a21ef8b32b82 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ open-garage==0.2.0 open-meteo==0.2.1 # homeassistant.components.openai_conversation -openai==0.26.2 +openai==0.27.2 # homeassistant.components.openerz openerz-api==0.2.0 diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr new file mode 100644 index 000000000000..bc06f51f416e --- /dev/null +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_default_prompt + list([ + dict({ + 'content': ''' + 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': 'system', + }), + dict({ + 'content': 'hello', + 'role': 'user', + }), + dict({ + 'content': 'Hello, how can I help you?', + 'role': 'assistant', + }), + ]) +# --- diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 25849882e823..4ce677d8cca6 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -6,8 +6,8 @@ import pytest from homeassistant import config_entries from homeassistant.components.openai_conversation.const import ( - CONF_MODEL, - DEFAULT_MODEL, + CONF_CHAT_MODEL, + DEFAULT_CHAT_MODEL, DOMAIN, ) from homeassistant.core import HomeAssistant @@ -72,7 +72,7 @@ async def test_options( assert options["type"] == FlowResultType.CREATE_ENTRY assert options["data"]["prompt"] == "Speak like a pirate" assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_MODEL] == DEFAULT_MODEL + assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL @pytest.mark.parametrize( diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 3b78a90f40ec..144d77beab55 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import patch from openai import error +from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant @@ -15,6 +16,7 @@ async def test_default_prompt( mock_init_component, area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test that the default prompt works.""" for i in range(3): @@ -86,40 +88,30 @@ async def test_default_prompt( model=3, suggested_area="Test Area 2", ) - with patch("openai.Completion.acreate") as mock_create: + with patch( + "openai.ChatCompletion.acreate", + return_value={ + "choices": [ + { + "message": { + "role": "assistant", + "content": "Hello, how can I help you?", + } + } + ] + }, + ) as mock_create: result = await conversation.async_converse(hass, "hello", None, Context()) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE - assert ( - mock_create.mock_calls[0][2]["prompt"] - == """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. - -Now finish this conversation: - -Smart home: How can I assist? -User: hello -Smart home: """ - ) + assert mock_create.mock_calls[0][2]["messages"] == snapshot async def test_error_handling(hass: HomeAssistant, mock_init_component) -> None: """Test that the default prompt works.""" - with patch("openai.Completion.acreate", side_effect=error.ServiceUnavailableError): + with patch( + "openai.ChatCompletion.acreate", side_effect=error.ServiceUnavailableError + ): result = await conversation.async_converse(hass, "hello", None, Context()) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -138,7 +130,7 @@ async def test_template_error( ) with patch( "openai.Engine.list", - ), patch("openai.Completion.acreate"): + ), patch("openai.ChatCompletion.acreate"): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse(hass, "hello", None, Context()) From 5dc96a6952edf659c9037cec7d2313748759f51f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:52:44 -1000 Subject: [PATCH 0928/1058] Fix unbound variable in sql when session setup fails (#90439) Traceback (most recent call last): File "/Users/bdraco/home-assistant/homeassistant/helpers/entity_platform.py", line 304, in _async_setup_platform await asyncio.shield(task) File "/Users/bdraco/home-assistant/homeassistant/components/sql/sensor.py", line 75, in async_setup_platform await async_setup_sensor( File "/Users/bdraco/home-assistant/homeassistant/components/sql/sensor.py", line 150, in async_setup_sensor sessmaker := await hass.async_add_executor_job( File "/opt/homebrew/Cellar/python@3.10/3.10.9/Frameworks/Python.framework/Versions/3.10/lib/python3.10/concurrent/futures/thread.py", line 58, in run result = self.fn(*self.args, **self.kwargs) File "/Users/bdraco/home-assistant/homeassistant/components/sql/sensor.py", line 205, in _validate_and_get_session_maker_for_db_url if sess: UnboundLocalError: local variable 'sess' referenced before assignment --- homeassistant/components/sql/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 57818ef27e4c..93d3e7be3553 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -180,11 +180,12 @@ def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | This does I/O and should be run in the executor. """ + sess: Session | None = None try: engine = sqlalchemy.create_engine(db_url, future=True) sessmaker = scoped_session(sessionmaker(bind=engine, future=True)) # Run a dummy query just to test the db_url - sess: Session = sessmaker() + sess = sessmaker() sess.execute(sqlalchemy.text("SELECT 1;")) except SQLAlchemyError as err: From 8096be768d1098547dcdfff813158cd94d9acf5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 17:54:03 -1000 Subject: [PATCH 0929/1058] Isolate the sql integration with a separate query cache (#90438) * Isolate the sql integration with a seperate query cache If there were a lot of sql integrations they could affect the performance of the recorder/logbook/history since they were sharing the same LRU and since the sql sensor updates frequently it would evict the recorder queries from the LRU. * generate in stmt * avoid double gen * Revert "avoid double gen" This reverts commit 6a5aa65268da12e2cd0e73e0bfb46db6e7e6214d. --- homeassistant/components/sql/sensor.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 93d3e7be3553..c19c2c258bc0 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -6,9 +6,12 @@ import decimal import logging import sqlalchemy +from sqlalchemy import lambda_stmt from sqlalchemy.engine import Result from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.util import LRUCache from homeassistant.components.recorder import CONF_DB_URL, get_instance from homeassistant.components.sensor import ( @@ -38,6 +41,8 @@ from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) +_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000) + def redact_credentials(data: str) -> str: """Redact credentials from string data.""" @@ -202,6 +207,12 @@ def _validate_and_get_session_maker_for_db_url(db_url: str) -> scoped_session | sess.close() +def _generate_lambda_stmt(query: str) -> StatementLambdaElement: + """Generate the lambda statement.""" + text = sqlalchemy.text(query) + return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) + + class SQLSensor(SensorEntity): """Representation of an SQL sensor.""" @@ -234,6 +245,7 @@ class SQLSensor(SensorEntity): self._attr_extra_state_attributes = {} self._attr_unique_id = unique_id self._use_database_executor = use_database_executor + self._lambda_stmt = _generate_lambda_stmt(query) if not yaml and unique_id: self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -255,7 +267,7 @@ class SQLSensor(SensorEntity): self._attr_extra_state_attributes = {} sess: scoped_session = self.sessionmaker() try: - result: Result = sess.execute(sqlalchemy.text(self._query)) + result: Result = sess.execute(self._lambda_stmt) except SQLAlchemyError as err: _LOGGER.error( "Error executing query %s: %s", From 12edaa052c26c258d4c6bb938f1a19cddd159797 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 29 Mar 2023 08:29:44 +0200 Subject: [PATCH 0930/1058] Fix data issue for energyzero during midnight (#90433) --- homeassistant/components/energyzero/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 8bdfb36ad750..05d23ca44645 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==0.3.1"] + "requirements": ["energyzero==0.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index aeb7033d0a6d..ef36e83a1552 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -652,7 +652,7 @@ emulated_roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.3.1 +energyzero==0.4.1 # homeassistant.components.enocean enocean==0.50 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a21ef8b32b82..0783ab3fc8e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -511,7 +511,7 @@ emulated_roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==0.3.1 +energyzero==0.4.1 # homeassistant.components.enocean enocean==0.50 From 0327f312f280d43132764fe4a70b3bf01089ee40 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Wed, 29 Mar 2023 08:37:20 +0200 Subject: [PATCH 0931/1058] Fix data issue for easyEnergy during midnight (#90434) --- 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 0954269628a9..803530fd6f84 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==0.2.2"] + "requirements": ["easyenergy==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef36e83a1552..c3311103909d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,7 +625,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.2 +easyenergy==0.2.3 # homeassistant.components.ebusd ebusdpy==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0783ab3fc8e3..11c99c589694 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,7 +493,7 @@ dynalite_devices==0.1.47 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==0.2.2 +easyenergy==0.2.3 # homeassistant.components.elgato elgato==4.0.1 From b58c90602fa73b1d9b528834bd083712c9fa5d42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Mar 2023 21:39:44 -1000 Subject: [PATCH 0932/1058] Bump yalexs-ble to 2.1.13 (#90442) --- 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 5528b7935384..07ecc2a1bec7 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.2.7", "yalexs-ble==2.1.12"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.13"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 6cff0dd8c69b..7c45f309e637 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.1.12"] + "requirements": ["yalexs-ble==2.1.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index c3311103909d..ea3c58add01b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.12 +yalexs-ble==2.1.13 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11c99c589694..3db436ada3af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1911,7 +1911,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.12 +yalexs-ble==2.1.13 # homeassistant.components.august yalexs==1.2.7 From c06bc28434233f29d78f257fcf8d5684c4d924ac Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Wed, 29 Mar 2023 04:34:29 -0400 Subject: [PATCH 0933/1058] Limit observations requested for NWS (#90137) * fetch data only for 70 minutes * Use timezone aware now * Type hint Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/nws/__init__.py | 7 ++++++- homeassistant/components/nws/const.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index fed7642605d7..ef0731ee94c5 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -26,6 +26,7 @@ from .const import ( COORDINATOR_OBSERVATION, DOMAIN, NWS_DATA, + UPDATE_TIME_PERIOD, ) _LOGGER = logging.getLogger(__name__) @@ -110,11 +111,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nws_data = SimpleNWS(latitude, longitude, api_key, client_session) await nws_data.set_station(station) + async def update_observation() -> None: + """Retrieve recent observations.""" + await nws_data.update_observation(start_time=utcnow() - UPDATE_TIME_PERIOD) + coordinator_observation = NwsDataUpdateCoordinator( hass, _LOGGER, name=f"NWS observation station {station}", - update_method=nws_data.update_observation, + update_method=update_observation, update_interval=DEFAULT_SCAN_INTERVAL, failed_update_interval=FAILED_SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 96844edd800c..109af7a565b8 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -82,3 +82,5 @@ COORDINATOR_FORECAST_HOURLY = "coordinator_forecast_hourly" OBSERVATION_VALID_TIME = timedelta(minutes=20) FORECAST_VALID_TIME = timedelta(minutes=45) +# A lot of stations update once hourly plus some wiggle room +UPDATE_TIME_PERIOD = timedelta(minutes=70) From 8dbcbd156adb3fa1f9c2e70e90936422313faa98 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 29 Mar 2023 09:33:27 +0000 Subject: [PATCH 0934/1058] Add new sensors to airq (#90413) Support for the sensors introduced in air-Q firmware v1.82.0 --- homeassistant/components/airq/sensor.py | 130 +++++++++++++++++++++++- 1 file changed, 125 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index a47c308279d8..7f0d51fcaa87 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -51,6 +51,13 @@ class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin) # Keys must match those in the data dictionary SENSOR_TYPES: list[AirQEntityDescription] = [ + AirQEntityDescription( + key="c2h4o", + name="Acetaldehyde", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("c2h4o"), + ), AirQEntityDescription( key="nh3_MR100", name="Ammonia", @@ -58,6 +65,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("nh3_MR100"), ), + AirQEntityDescription( + key="ash3", + name="Arsine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ash3"), + ), + AirQEntityDescription( + key="br2", + name="Bromine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("br2"), + ), + AirQEntityDescription( + key="ch4s", + name="CH4S", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ch4s"), + ), AirQEntityDescription( key="cl2_M20", name="Chlorine", @@ -65,6 +93,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("cl2_M20"), ), + AirQEntityDescription( + key="clo2", + name="ClO2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("clo2"), + ), AirQEntityDescription( key="co", name="CO", @@ -80,6 +115,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("co2"), ), + AirQEntityDescription( + key="cs2", + name="CS2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("cs2"), + ), AirQEntityDescription( key="dewpt", name="Dew point", @@ -95,6 +137,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ethanol"), ), + AirQEntityDescription( + key="c2h4", + name="Ethylene", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("c2h4"), + ), AirQEntityDescription( key="ch2o_M10", name="Formaldehyde", @@ -102,6 +151,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("ch2o_M10"), ), + AirQEntityDescription( + key="f2", + name="Fluorine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("f2"), + ), AirQEntityDescription( key="h2s", name="H2S", @@ -109,6 +165,27 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2s"), ), + AirQEntityDescription( + key="hcl", + name="HCl", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcl"), + ), + AirQEntityDescription( + key="hcn", + name="HCN", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hcn"), + ), + AirQEntityDescription( + key="hf", + name="HF", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("hf"), + ), AirQEntityDescription( key="health", name="Health Index", @@ -140,6 +217,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("h2_M1000"), ), + AirQEntityDescription( + key="h2o2", + name="Hydrogen peroxide", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("h2o2"), + ), AirQEntityDescription( key="ch4_MIPEX", name="Methane", @@ -172,12 +256,11 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("no2"), ), AirQEntityDescription( - key="o3", - name="Ozone", - device_class=SensorDeviceClass.OZONE, - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + key="acid_M100", + name="Organic acid", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - value=lambda data: data.get("o3"), + value=lambda data: data.get("acid_M100"), ), AirQEntityDescription( key="oxygen", @@ -187,6 +270,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ value=lambda data: data.get("oxygen"), icon="mdi:leaf", ), + AirQEntityDescription( + key="o3", + name="Ozone", + device_class=SensorDeviceClass.OZONE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("o3"), + ), AirQEntityDescription( key="performance", name="Performance Index", @@ -195,6 +286,13 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ icon="mdi:head-check", value=lambda data: data.get("performance", 0.0) / 10.0, ), + AirQEntityDescription( + key="ph3", + name="PH3", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ph3"), + ), AirQEntityDescription( key="pm1", name="PM1", @@ -245,6 +343,20 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("c3h8_MIPEX"), ), + AirQEntityDescription( + key="refigerant", + name="Refrigerant", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("refigerant"), + ), + AirQEntityDescription( + key="sih4", + name="SiH4", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("sih4"), + ), AirQEntityDescription( key="so2", name="SO2", @@ -299,6 +411,14 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("tvoc_ionsc"), ), + AirQEntityDescription( + key="virus", + name="Virus Index", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:virus-off", + value=lambda data: data.get("virus", 0.0), + ), ] From d427c35c871ff3e4be4adbc803d7bfc9677b624c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Mar 2023 14:41:38 +0200 Subject: [PATCH 0935/1058] Reolink improve config flow login (#90036) --- .../components/reolink/config_flow.py | 5 ++++- homeassistant/components/reolink/host.py | 18 ++++++++++-------- homeassistant/components/reolink/strings.json | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 15f3dfa613ec..a29871f28dc1 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -108,7 +108,10 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" errors = {} - placeholders = {"error": ""} + placeholders = { + "error": "", + "troubleshooting_link": "https://www.home-assistant.io/integrations/reolink/#troubleshooting", + } if user_input is not None: if CONF_HOST not in user_input: diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 1c0f97b6a2d6..f7810746481b 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -82,9 +82,15 @@ class ReolinkHost: f"'{self._api.user_level}', only admin users can change camera settings" ) + enable_rtsp = None enable_onvif = None enable_rtmp = None - enable_rtsp = None + + if not self._api.rtsp_enabled: + _LOGGER.debug( + "RTSP is disabled on %s, trying to enable it", self._api.nvr_name + ) + enable_rtsp = True if not self._api.onvif_enabled: _LOGGER.debug( @@ -97,11 +103,6 @@ class ReolinkHost: "RTMP is disabled on %s, trying to enable it", self._api.nvr_name ) enable_rtmp = True - elif not self._api.rtsp_enabled and self._api.protocol == "rtsp": - _LOGGER.debug( - "RTSP is disabled on %s, trying to enable it", self._api.nvr_name - ) - enable_rtsp = True if enable_onvif or enable_rtmp or enable_rtsp: try: @@ -112,13 +113,14 @@ class ReolinkHost: ) except ReolinkError: ports = "" + if enable_rtsp: + ports += "RTSP " + if enable_onvif: ports += "ONVIF " if enable_rtmp: ports += "RTMP " - elif enable_rtsp: - ports += "RTSP " ir.async_create_issue( self._hass, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 50c561530e57..c36001e0377d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -3,7 +3,7 @@ "flow_title": "{hostname} ({ip_address})", "step": { "user": { - "description": "{error}", + "description": "See the [troubleshooting steps]({troubleshooting_link}) if you encounter problems. {error}", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", @@ -19,7 +19,7 @@ }, "error": { "api_error": "API error occurred", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%], check the IP address of the camera and see the troubleshooting steps in the documentation", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''", "unknown": "[%key:common::config_flow::error::unknown%]" From a1c94919ded23ae1201e891ddff48620adaefc4b Mon Sep 17 00:00:00 2001 From: mletenay Date: Wed, 29 Mar 2023 16:01:14 +0200 Subject: [PATCH 0936/1058] Fix goodwe export limit unit on single phase DT inverters (#90427) * Fix export limit unit on single phase DT inverters * Update homeassistant/components/goodwe/number.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/goodwe/manifest.json | 2 +- homeassistant/components/goodwe/number.py | 13 +++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 53e093758cc3..8dad8454d6b9 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.25"] + "requirements": ["goodwe==0.2.29"] } diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 9f997daec40c..3f9714aa372a 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -39,8 +39,13 @@ class GoodweNumberEntityDescription( """Class describing Goodwe number entities.""" +def _get_setting_unit(inverter: Inverter, setting: str) -> str: + """Return the unit of an inverter setting.""" + return next((s.unit for s in inverter.settings() if s.id_ == setting), "") + + NUMBERS = ( - # non DT inverters (limit in W) + # Export limit in W GoodweNumberEntityDescription( key="grid_export_limit", name="Grid export limit", @@ -53,9 +58,9 @@ NUMBERS = ( native_max_value=10000, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - filter=lambda inv: type(inv).__name__ != "DT", + filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") != "%", ), - # DT inverters (limit is in %) + # Export limit in % GoodweNumberEntityDescription( key="grid_export_limit", name="Grid export limit", @@ -67,7 +72,7 @@ NUMBERS = ( native_max_value=100, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - filter=lambda inv: type(inv).__name__ == "DT", + filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%", ), GoodweNumberEntityDescription( key="battery_discharge_depth", diff --git a/requirements_all.txt b/requirements_all.txt index ea3c58add01b..f0c54881fc99 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -798,7 +798,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.25 +goodwe==0.2.29 # homeassistant.components.google_mail google-api-python-client==2.71.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3db436ada3af..ea2d06816ed4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -614,7 +614,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.25 +goodwe==0.2.29 # homeassistant.components.google_mail google-api-python-client==2.71.0 From f7925763a46a52cf7f6191285a309d7d2030b906 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:20:51 +0200 Subject: [PATCH 0937/1058] Make abort_entries_match available in options flow (#90406) * Make abort_entries_match available in options flow * Add tests * Exclude ignore entries and add test * Move to OptionsFlow * Adjust tests * Use mock_config_flow * Use AbortFlow * Remove duplicate code --- homeassistant/components/imap/config_flow.py | 67 ++++++-------- homeassistant/config_entries.py | 58 +++++++++--- tests/test_config_entries.py | 93 ++++++++++++++++++++ 3 files changed, 167 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index c855d099b4ad..8dd3019878f8 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_PORT, 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 config_validation as cv from .const import ( @@ -148,50 +148,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): """Option flow handler.""" - def _async_abort_entries_match( - self, match_dict: dict[str, Any] | None - ) -> dict[str, str]: - """Validate the user input against other config entries.""" - if match_dict is None: - return {} - - errors: dict[str, str] = {} - for entry in [ - entry - for entry in self.hass.config_entries.async_entries(DOMAIN) - if entry is not self.config_entry - ]: - if all(item in entry.data.items() for item in match_dict.items()): - errors["base"] = "already_configured" - break - return errors - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" - errors: dict[str, str] = self._async_abort_entries_match( - { - CONF_SERVER: self._config_entry.data[CONF_SERVER], - CONF_USERNAME: self._config_entry.data[CONF_USERNAME], - CONF_FOLDER: user_input[CONF_FOLDER], - CONF_SEARCH: user_input[CONF_SEARCH], - } - if user_input - else None - ) + errors: dict[str, str] | None = None entry_data: dict[str, Any] = dict(self._config_entry.data) - if not errors and user_input is not None: - entry_data.update(user_input) - errors = await validate_input(entry_data) - if not errors: - self.hass.config_entries.async_update_entry( - self.config_entry, data=entry_data + if user_input is not None: + try: + self._async_abort_entries_match( + { + CONF_SERVER: self._config_entry.data[CONF_SERVER], + CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_FOLDER: user_input[CONF_FOLDER], + CONF_SEARCH: user_input[CONF_SEARCH], + } + if user_input + else None ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) - return self.async_create_entry(data={}) + except AbortFlow as err: + errors = {"base": err.reason} + else: + entry_data.update(user_input) + errors = await validate_input(entry_data) + if not errors: + self.hass.config_entries.async_update_entry( + self.config_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self.config_entry.entry_id + ) + ) + return self.async_create_entry(data={}) schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b21ae391e2a8..454cfeade277 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1468,6 +1468,28 @@ async def _old_conf_migrator(old_config: dict[str, Any]) -> dict[str, Any]: return {"entries": old_config} +@callback +def _async_abort_entries_match( + other_entries: list[ConfigEntry], match_dict: dict[str, Any] | None = None +) -> None: + """Abort if current entries match all data. + + Requires `already_configured` in strings.json in user visible flows. + """ + if match_dict is None: + match_dict = {} # Match any entry + for entry in other_entries: + if all( + item + in ChainMap( + entry.options, # type: ignore[arg-type] + entry.data, # type: ignore[arg-type] + ).items() + for item in match_dict.items() + ): + raise data_entry_flow.AbortFlow("already_configured") + + class ConfigFlow(data_entry_flow.FlowHandler): """Base class for config flows with some helpers.""" @@ -1505,18 +1527,9 @@ class ConfigFlow(data_entry_flow.FlowHandler): Requires `already_configured` in strings.json in user visible flows. """ - if match_dict is None: - match_dict = {} # Match any entry - for entry in self._async_current_entries(include_ignore=False): - if all( - item - in ChainMap( - entry.options, # type: ignore[arg-type] - entry.data, # type: ignore[arg-type] - ).items() - for item in match_dict.items() - ): - raise data_entry_flow.AbortFlow("already_configured") + _async_abort_entries_match( + self._async_current_entries(include_ignore=False), match_dict + ) @callback def _abort_if_unique_id_configured( @@ -1858,6 +1871,27 @@ class OptionsFlow(data_entry_flow.FlowHandler): handler: str + @callback + def _async_abort_entries_match( + self, match_dict: dict[str, Any] | None = None + ) -> None: + """Abort if another current entry matches all data. + + Requires `already_configured` in strings.json in user visible flows. + """ + + config_entry = cast( + ConfigEntry, self.hass.config_entries.async_get_entry(self.handler) + ) + _async_abort_entries_match( + [ + entry + for entry in self.hass.config_entries.async_entries(config_entry.domain) + if entry is not config_entry and entry.source != SOURCE_IGNORE + ], + match_dict, + ) + class OptionsFlowWithConfigEntry(OptionsFlow): """Base class for options flows with config entry and options.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index c8cdc5619858..60b9a250c172 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -40,6 +40,7 @@ from .common import ( MockModule, MockPlatform, async_fire_time_changed, + mock_config_flow, mock_coro, mock_entity_platform, mock_integration, @@ -3388,6 +3389,98 @@ async def test__async_abort_entries_match( assert result["reason"] == reason +@pytest.mark.parametrize( + ("matchers", "reason"), + [ + ({}, "already_configured"), + ({"host": "3.3.3.3"}, "no_match"), + ({"vendor": "no_match"}, "no_match"), + ({"host": "3.4.5.6"}, "already_configured"), + ({"host": "3.4.5.6", "ip": "3.4.5.6"}, "no_match"), + ({"host": "3.4.5.6", "ip": "1.2.3.4"}, "already_configured"), + ({"host": "3.4.5.6", "ip": "1.2.3.4", "port": 23}, "already_configured"), + ( + {"host": "9.9.9.9", "ip": "6.6.6.6", "port": 12, "vendor": "zoo"}, + "already_configured", + ), + ({"vendor": "zoo"}, "already_configured"), + ({"ip": "9.9.9.9"}, "already_configured"), + ({"ip": "7.7.7.7"}, "no_match"), # ignored + ({"vendor": "data"}, "no_match"), + ( + {"vendor": "options"}, + "already_configured", + ), # ensure options takes precedence over data + ], +) +async def test__async_abort_entries_match_options_flow( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + matchers: dict[str, str], + reason: str, +) -> None: + """Test aborting if matching config entries exist.""" + MockConfigEntry( + domain="test_abort", data={"ip": "1.2.3.4", "host": "4.5.6.7", "port": 23} + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", data={"ip": "9.9.9.9", "host": "4.5.6.7", "port": 23} + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", data={"ip": "1.2.3.4", "host": "3.4.5.6", "port": 23} + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", + source=config_entries.SOURCE_IGNORE, + data={"ip": "7.7.7.7", "host": "4.5.6.7", "port": 23}, + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", + data={"ip": "6.6.6.6", "host": "9.9.9.9", "port": 12}, + options={"vendor": "zoo"}, + ).add_to_hass(hass) + MockConfigEntry( + domain="test_abort", + data={"vendor": "data"}, + options={"vendor": "options"}, + ).add_to_hass(hass) + + original_entry = MockConfigEntry(domain="test_abort", data={}) + original_entry.add_to_hass(hass) + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule("test_abort", async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, "config_flow.test_abort", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + class _OptionsFlow(config_entries.OptionsFlow): + """Test flow.""" + + async def async_step_init(self, user_input=None): + """Test user step.""" + if errors := self._async_abort_entries_match(user_input): + return self.async_abort(reason=errors["base"]) + return self.async_abort(reason="no_match") + + return _OptionsFlow() + + with mock_config_flow("test_abort", TestFlow): + result = await hass.config_entries.options.async_init( + original_entry.entry_id, data=matchers + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + async def test_loading_old_data( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: From 81c39e42f49b23a82e667421d222e365f9765bdf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Mar 2023 12:25:08 -0400 Subject: [PATCH 0938/1058] Bump home-assistant-intents to 2023.3.29 (#90459) --- 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 7630eed01f19..0753fcd5af9e 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.0.6", "home-assistant-intents==2023.2.28"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.3.29"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dea105f29674..7a1ae9b5c496 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 home-assistant-frontend==20230309.1 -home-assistant-intents==2023.2.28 +home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index f0c54881fc99..15553abc14ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -910,7 +910,7 @@ holidays==0.21.13 home-assistant-frontend==20230309.1 # homeassistant.components.conversation -home-assistant-intents==2023.2.28 +home-assistant-intents==2023.3.29 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea2d06816ed4..cd04c60f1525 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -696,7 +696,7 @@ holidays==0.21.13 home-assistant-frontend==20230309.1 # homeassistant.components.conversation -home-assistant-intents==2023.2.28 +home-assistant-intents==2023.3.29 # homeassistant.components.home_connect homeconnect==0.7.2 From 7ca5beddfc750b4215ef721409cf3aa7a281fe1a Mon Sep 17 00:00:00 2001 From: Luke Date: Wed, 29 Mar 2023 12:36:01 -0400 Subject: [PATCH 0939/1058] Fix Oralb Logger (#90460) --- homeassistant/components/oralb/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/oralb/manifest.json b/homeassistant/components/oralb/manifest.json index a1071cc0a11c..adf72f5fe56f 100644 --- a/homeassistant/components/oralb/manifest.json +++ b/homeassistant/components/oralb/manifest.json @@ -11,6 +11,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/oralb", "iot_class": "local_push", - "loggers": ["oralb-ble"], + "loggers": ["oralb_ble"], "requirements": ["oralb-ble==0.17.6"] } From a33c70e59557eecfb376508b5ad3969d88f4c999 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 Mar 2023 18:52:21 +0200 Subject: [PATCH 0940/1058] Bump python-matter-server to 3.2.0 (#90457) --- 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 b81ac2c62b8d..190bf33dcf71 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==3.1.0"] + "requirements": ["python-matter-server==3.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15553abc14ea..ea4a5c918a8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2078,7 +2078,7 @@ python-kasa==0.5.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.1.0 +python-matter-server==3.2.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd04c60f1525..daa52bc31869 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1492,7 +1492,7 @@ python-juicenet==1.1.0 python-kasa==0.5.1 # homeassistant.components.matter -python-matter-server==3.1.0 +python-matter-server==3.2.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From b881995efc82fbe71fecfdb8af7b87ab2e868436 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Mar 2023 19:36:42 +0200 Subject: [PATCH 0941/1058] Add verify ssl option to nextcloud (#90462) add verify sssl option to config flow --- homeassistant/components/nextcloud/__init__.py | 6 +++++- homeassistant/components/nextcloud/config_flow.py | 7 +++++-- homeassistant/components/nextcloud/const.py | 1 + homeassistant/components/nextcloud/strings.json | 3 ++- .../components/nextcloud/snapshots/test_config_flow.ambr | 2 ++ tests/components/nextcloud/test_config_flow.py | 9 +++++++-- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index d2514b9091db..60489b3e30dc 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -10,6 +10,7 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_URL, CONF_USERNAME, + CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant @@ -73,7 +74,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _connect_nc(): return NextcloudMonitor( - entry.data[CONF_URL], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + entry.data[CONF_URL], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_VERIFY_SSL], ) try: diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index e297a6893a7b..f22d0a01a552 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -8,16 +8,17 @@ from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN +from .const import DEFAULT_VERIFY_SSL, DOMAIN DATA_SCHEMA_USER = vol.Schema( { vol.Required(CONF_URL): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, } ) _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_URL], user_input[CONF_USERNAME], user_input[CONF_PASSWORD], + user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: @@ -51,6 +53,7 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): CONF_URL: user_input[CONF_URL], CONF_PASSWORD: user_input[CONF_PASSWORD], CONF_USERNAME: user_input[CONF_USERNAME], + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, } ) diff --git a/homeassistant/components/nextcloud/const.py b/homeassistant/components/nextcloud/const.py index 223d21771beb..248128dd538c 100644 --- a/homeassistant/components/nextcloud/const.py +++ b/homeassistant/components/nextcloud/const.py @@ -3,3 +3,4 @@ from datetime import timedelta DOMAIN = "nextcloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_VERIFY_SSL = True diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index 9ae7ed24a60f..dc0175ea8e8e 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -7,7 +7,8 @@ "data": { "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr index 0c9df1238cf5..caa952850749 100644 --- a/tests/components/nextcloud/snapshots/test_config_flow.ambr +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -4,6 +4,7 @@ 'password': 'nc_pass', 'url': 'nc_url', 'username': 'nc_user', + 'verify_ssl': True, }) # --- # name: test_user_create_entry @@ -11,5 +12,6 @@ 'password': 'nc_pass', 'url': 'nc_url', 'username': 'nc_user', + 'verify_ssl': True, }) # --- diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index 118d8fef0dad..582ad3e77a3d 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -7,7 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.nextcloud import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -15,7 +15,12 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -VALID_CONFIG = {CONF_URL: "nc_url", CONF_USERNAME: "nc_user", CONF_PASSWORD: "nc_pass"} +VALID_CONFIG = { + CONF_URL: "nc_url", + CONF_USERNAME: "nc_user", + CONF_PASSWORD: "nc_pass", + CONF_VERIFY_SSL: True, +} async def test_user_create_entry( From 4877cf8d5daf19eb1dc975194c077a9611cc47cd Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 29 Mar 2023 14:30:30 -0400 Subject: [PATCH 0942/1058] Bump zwave-js-server-python to 0.47.1 (#90464) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 0ad934103d6a..5fb7726577bf 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index ea4a5c918a8f..39dad08e2b57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zigpy==0.53.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.0 +zwave-js-server-python==0.47.1 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daa52bc31869..bfa314909524 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1953,7 +1953,7 @@ zigpy-znp==0.9.3 zigpy==0.53.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.0 +zwave-js-server-python==0.47.1 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 From a478e278fdbedec0130cb65760512d3a43439643 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 29 Mar 2023 21:04:04 +0200 Subject: [PATCH 0943/1058] Update frontend to 20230329.0 (#90461) --- 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 2c13e81ee3c2..8c3fb8c1434b 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==20230309.1"] + "requirements": ["home-assistant-frontend==20230329.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7a1ae9b5c496..0ed98c78e1df 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230309.1 +home-assistant-frontend==20230329.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 39dad08e2b57..d51947b81cfb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230309.1 +home-assistant-frontend==20230329.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfa314909524..514e653d3464 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230309.1 +home-assistant-frontend==20230329.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 28d045cf75f85526c2d0a1cb64085e7e003406aa Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 29 Mar 2023 21:05:20 +0200 Subject: [PATCH 0944/1058] Allow resetting filters for waze_travel_time (#88253) * Allow resetting filters by using vol.Maybe * Fix return types * Use suggested values * Apply feedback * Apply nitpick --- .../waze_travel_time/config_flow.py | 52 ++++++------------- .../components/waze_travel_time/sensor.py | 19 ++++--- 2 files changed, 27 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index b26732e4cb1f..b885da3f37be 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -31,6 +31,19 @@ from .const import ( ) from .helpers import is_valid_config_entry +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_INCL_FILTER, default=""): cv.string, + vol.Optional(CONF_EXCL_FILTER, default=""): cv.string, + vol.Optional(CONF_REALTIME): cv.boolean, + vol.Optional(CONF_VEHICLE_TYPE): vol.In(VEHICLE_TYPES), + vol.Optional(CONF_UNITS): vol.In(UNITS), + vol.Optional(CONF_AVOID_TOLL_ROADS): cv.boolean, + vol.Optional(CONF_AVOID_SUBSCRIPTION_ROADS): cv.boolean, + vol.Optional(CONF_AVOID_FERRIES): cv.boolean, + } +) + def default_options(hass: HomeAssistant) -> dict[str, str | bool]: """Get the default options.""" @@ -57,43 +70,8 @@ class WazeOptionsFlow(config_entries.OptionsFlow): return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_INCL_FILTER, - default=self.config_entry.options.get(CONF_INCL_FILTER, ""), - ): cv.string, - vol.Optional( - CONF_EXCL_FILTER, - default=self.config_entry.options.get(CONF_EXCL_FILTER, ""), - ): cv.string, - vol.Optional( - CONF_REALTIME, - default=self.config_entry.options[CONF_REALTIME], - ): cv.boolean, - vol.Optional( - CONF_VEHICLE_TYPE, - default=self.config_entry.options[CONF_VEHICLE_TYPE], - ): vol.In(VEHICLE_TYPES), - vol.Optional( - CONF_UNITS, - default=self.config_entry.options[CONF_UNITS], - ): vol.In(UNITS), - vol.Optional( - CONF_AVOID_TOLL_ROADS, - default=self.config_entry.options[CONF_AVOID_TOLL_ROADS], - ): cv.boolean, - vol.Optional( - CONF_AVOID_SUBSCRIPTION_ROADS, - default=self.config_entry.options[ - CONF_AVOID_SUBSCRIPTION_ROADS - ], - ): cv.boolean, - vol.Optional( - CONF_AVOID_FERRIES, - default=self.config_entry.options[CONF_AVOID_FERRIES], - ): cv.boolean, - } + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options ), ) diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index ecbf3e9e12a0..cf709805f6d1 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -60,8 +60,6 @@ async def async_setup_entry( name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) data = WazeTravelTimeData( - None, - None, region, config_entry, ) @@ -85,7 +83,14 @@ class WazeTravelTime(SensorEntity): configuration_url="https://www.waze.com", ) - def __init__(self, unique_id, name, origin, destination, waze_data): + def __init__( + self, + unique_id: str, + name: str, + origin: str, + destination: str, + waze_data: WazeTravelTimeData, + ) -> None: """Initialize the Waze travel time sensor.""" self._attr_unique_id = unique_id self._waze_data = waze_data @@ -126,7 +131,7 @@ class WazeTravelTime(SensorEntity): "destination": self._waze_data.destination, } - async def first_update(self, _=None): + async def first_update(self, _=None) -> None: """Run first update and write state.""" await self.hass.async_add_executor_job(self.update) self.async_write_ha_state() @@ -142,12 +147,12 @@ class WazeTravelTime(SensorEntity): class WazeTravelTimeData: """WazeTravelTime Data object.""" - def __init__(self, origin, destination, region, config_entry): + def __init__(self, region: str, config_entry: ConfigEntry) -> None: """Set up WazeRouteCalculator.""" - self.origin = origin - self.destination = destination self.region = region self.config_entry = config_entry + self.origin: str | None = None + self.destination: str | None = None self.duration = None self.distance = None self.route = None From cf0550f5c279eba18c55ce5289ffd75e23e994f9 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 29 Mar 2023 21:46:08 +0200 Subject: [PATCH 0945/1058] Add re-auth flow to nextcloud (#90472) --- .../components/nextcloud/__init__.py | 15 +- .../components/nextcloud/config_flow.py | 64 ++++++++- .../components/nextcloud/strings.json | 13 +- .../nextcloud/snapshots/test_config_flow.ambr | 8 ++ .../components/nextcloud/test_config_flow.py | 133 +++++++++++++++++- 5 files changed, 221 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 60489b3e30dc..65829f713ef5 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,7 +1,12 @@ """The Nextcloud integration.""" import logging -from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitor, + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorRequestError, +) import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -14,6 +19,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType @@ -82,9 +88,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: ncm = await hass.async_add_executor_job(_connect_nc) - except NextcloudMonitorError: - _LOGGER.error("Nextcloud setup failed - Check configuration") - return False + except NextcloudMonitorAuthorizationError as ex: + raise ConfigEntryAuthFailed from ex + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError) as ex: + raise ConfigEntryNotReady from ex coordinator = NextcloudDataUpdateCoordinator( hass, diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index f22d0a01a552..c5019603c09c 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -1,13 +1,20 @@ """Config flow to configure the Nextcloud integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any -from nextcloudmonitor import NextcloudMonitor, NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitor, + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorError, + NextcloudMonitorRequestError, +) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult @@ -21,6 +28,13 @@ DATA_SCHEMA_USER = vol.Schema( vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, } ) +DATA_SCHEMA_REAUTH = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + _LOGGER = logging.getLogger(__name__) @@ -29,6 +43,8 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _entry: ConfigEntry | None = None + def _try_connect_nc(self, user_input: dict) -> NextcloudMonitor: """Try to connect to nextcloud server.""" return NextcloudMonitor( @@ -67,7 +83,9 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) try: await self.hass.async_add_executor_job(self._try_connect_nc, user_input) - except NextcloudMonitorError: + except NextcloudMonitorAuthorizationError: + errors["base"] = "invalid_auth" + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError): errors["base"] = "connection_error" else: return self.async_create_entry( @@ -79,3 +97,43 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): 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 flow upon an API authentication error.""" + self._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: + """Handle reauthorization flow.""" + errors = {} + assert self._entry is not None + + if user_input is not None: + try: + await self.hass.async_add_executor_job( + self._try_connect_nc, {**self._entry.data, **user_input} + ) + except NextcloudMonitorAuthorizationError: + errors["base"] = "invalid_auth" + except (NextcloudMonitorConnectionError, NextcloudMonitorRequestError): + errors["base"] = "connection_error" + else: + self.hass.config_entries.async_update_entry( + 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") + + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA_REAUTH, + {CONF_USERNAME: self._entry.data[CONF_USERNAME], **(user_input or {})}, + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + description_placeholders={"url": self._entry.data[CONF_URL]}, + errors=errors, + ) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index dc0175ea8e8e..782865032af8 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -10,14 +10,23 @@ "password": "[%key:common::config_flow::data::password%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } + }, + "reauth_confirm": { + "description": "Update your login information for {url}.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "connection_error_during_import": "Connection error occured during yaml configuration import" + "connection_error_during_import": "Connection error occured during yaml configuration import", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { - "connection_error": "[%key:common::config_flow::error::cannot_connect%]" + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } }, "issues": { diff --git a/tests/components/nextcloud/snapshots/test_config_flow.ambr b/tests/components/nextcloud/snapshots/test_config_flow.ambr index caa952850749..3334478ba245 100644 --- a/tests/components/nextcloud/snapshots/test_config_flow.ambr +++ b/tests/components/nextcloud/snapshots/test_config_flow.ambr @@ -7,6 +7,14 @@ 'verify_ssl': True, }) # --- +# name: test_reauth + dict({ + 'password': 'other_password', + 'url': 'nc_url', + 'username': 'other_user', + 'verify_ssl': True, + }) +# --- # name: test_user_create_entry dict({ 'password': 'nc_pass', diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index 582ad3e77a3d..ba465c5f8a72 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -1,12 +1,17 @@ """Tests for the Nextcloud config flow.""" from unittest.mock import Mock, patch -from nextcloudmonitor import NextcloudMonitorError +from nextcloudmonitor import ( + NextcloudMonitorAuthorizationError, + NextcloudMonitorConnectionError, + NextcloudMonitorError, + NextcloudMonitorRequestError, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.nextcloud import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -27,6 +32,7 @@ async def test_user_create_entry( hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion ) -> None: """Test that the user step works.""" + # start user flow result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -34,9 +40,24 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {} + # test NextcloudMonitorAuthorizationError with patch( "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", - side_effect=NextcloudMonitorError, + side_effect=NextcloudMonitorAuthorizationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # test NextcloudMonitorConnectionError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorConnectionError, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -47,6 +68,21 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {"base": "connection_error"} + # test NextcloudMonitorRequestError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorRequestError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_error"} + + # test success with patch( "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", return_value=mock_nextcloud_monitor, @@ -154,3 +190,94 @@ async def test_import_connection_error(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == FlowResultType.ABORT assert result["reason"] == "connection_error_during_import" + + +async def test_reauth( + hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion +) -> None: + """Test that the re-auth flow works.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="nc_url", + unique_id="nc_url", + data=VALID_CONFIG, + ) + entry.add_to_hass(hass) + + # start reauth flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": entry.entry_id}, + data=entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # test NextcloudMonitorAuthorizationError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorAuthorizationError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # test NextcloudMonitorConnectionError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorConnectionError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "connection_error"} + + # test NextcloudMonitorRequestError + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + side_effect=NextcloudMonitorRequestError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "connection_error"} + + # test success + with patch( + "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", + return_value=mock_nextcloud_monitor, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "other_user", + CONF_PASSWORD: "other_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == snapshot From 5bc9545b81d016c002c7672daec237a580bce74f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Mar 2023 21:58:25 +0200 Subject: [PATCH 0946/1058] Rename custom_jinja to custom_templates (#90473) Co-authored-by: Franck Nijhof --- homeassistant/bootstrap.py | 2 +- .../components/homeassistant/__init__.py | 14 +++++++------- .../components/homeassistant/services.yaml | 4 ++-- homeassistant/helpers/template.py | 12 ++++++------ tests/components/homeassistant/test_init.py | 16 ++++++++-------- tests/helpers/test_template.py | 6 +++--- .../inner/inner_test.jinja | 0 .../test.jinja | 0 8 files changed, 27 insertions(+), 27 deletions(-) rename tests/testing_config/{custom_jinja => custom_templates}/inner/inner_test.jinja (100%) rename tests/testing_config/{custom_jinja => custom_templates}/test.jinja (100%) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index eb3aa3a22399..445ff35793c9 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -245,7 +245,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: entity_registry.async_load(hass), issue_registry.async_load(hass), hass.async_add_executor_job(_cache_uname_processor), - template.async_load_custom_jinja(hass), + template.async_load_custom_templates(hass), ) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 4b033fd7119c..91dd742e802b 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -30,7 +30,7 @@ from homeassistant.helpers.service import ( async_extract_referenced_entity_ids, async_register_admin_service, ) -from homeassistant.helpers.template import async_load_custom_jinja +from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType ATTR_ENTRY_ID = "entry_id" @@ -39,7 +39,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = ha.DOMAIN SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry" -SERVICE_RELOAD_CUSTOM_JINJA = "reload_custom_jinja" +SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates" SERVICE_CHECK_CONFIG = "check_config" SERVICE_UPDATE_ENTITY = "update_entity" SERVICE_SET_LOCATION = "set_location" @@ -260,12 +260,12 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), ) - async def async_handle_reload_jinja(call: ha.ServiceCall) -> None: + async def async_handle_reload_templates(call: ha.ServiceCall) -> None: """Service handler to reload custom Jinja.""" - await async_load_custom_jinja(hass) + await async_load_custom_templates(hass) async_register_admin_service( - hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA, async_handle_reload_jinja + hass, ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES, async_handle_reload_templates ) async def async_handle_reload_config_entry(call: ha.ServiceCall) -> None: @@ -300,7 +300,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no Additionally, it also calls the `homeasssitant.reload_core_config` service, as that reloads the core YAML configuration, the `frontend.reload_themes` service that reloads the themes, and the - `homeassistant.reload_custom_jinja` service that reloads any custom + `homeassistant.reload_custom_templates` service that reloads any custom jinja into memory. We only do so, if there are no configuration errors. @@ -330,7 +330,7 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no for domain, service in ( (ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG), ("frontend", "reload_themes"), - (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_JINJA), + (ha.DOMAIN, SERVICE_RELOAD_CUSTOM_TEMPLATES), ) ] diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 20f23402a738..2fe27769c3fb 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -59,10 +59,10 @@ update_entity: target: entity: {} -reload_custom_jinja: +reload_custom_templates: name: Reload custom Jinja2 templates description: >- - Reload Jinja2 templates found in the custom_jinja folder in your config. + Reload Jinja2 templates found in the custom_templates folder in your config. New values will be applied on the next render of the template. reload_config_entry: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d3aa7c81ffbb..481a59cee858 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -124,7 +124,7 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 -MAX_CUSTOM_JINJA_SIZE = 5 * 1024 * 1024 +MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 @bind_hass @@ -2084,18 +2084,18 @@ class LoggingUndefined(jinja2.Undefined): return super().__bool__() -async def async_load_custom_jinja(hass: HomeAssistant) -> None: +async def async_load_custom_templates(hass: HomeAssistant) -> None: """Load all custom jinja files under 5MiB into memory.""" - return await hass.async_add_executor_job(_load_custom_jinja, hass) + return await hass.async_add_executor_job(_load_custom_templates, hass) -def _load_custom_jinja(hass: HomeAssistant) -> None: +def _load_custom_templates(hass: HomeAssistant) -> None: result = {} - jinja_path = hass.config.path("custom_jinja") + jinja_path = hass.config.path("custom_templates") all_files = [ item for item in pathlib.Path(jinja_path).rglob("*.jinja") - if item.is_file() and item.stat().st_size <= MAX_CUSTOM_JINJA_SIZE + if item.is_file() and item.stat().st_size <= MAX_CUSTOM_TEMPLATE_SIZE ] for file in all_files: content = file.read_text() diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 4a0424169652..652fc4a1fdda 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.homeassistant import ( SERVICE_CHECK_CONFIG, SERVICE_RELOAD_ALL, SERVICE_RELOAD_CORE_CONFIG, - SERVICE_RELOAD_CUSTOM_JINJA, + SERVICE_RELOAD_CUSTOM_TEMPLATES, SERVICE_SET_LOCATION, ) from homeassistant.const import ( @@ -576,19 +576,19 @@ async def test_save_persistent_states(hass: HomeAssistant) -> None: assert mock_save.called -async def test_reload_custom_jinja(hass: HomeAssistant) -> None: - """Test we can call reload_custom_jinja.""" +async def test_reload_custom_templates(hass: HomeAssistant) -> None: + """Test we can call reload_custom_templates.""" await async_setup_component(hass, "homeassistant", {}) with patch( - "homeassistant.components.homeassistant.async_load_custom_jinja", + "homeassistant.components.homeassistant.async_load_custom_templates", return_value=None, - ) as mock_load_custom_jinja: + ) as mock_load_custom_templates: await hass.services.async_call( "homeassistant", - SERVICE_RELOAD_CUSTOM_JINJA, + SERVICE_RELOAD_CUSTOM_TEMPLATES, blocking=True, ) - assert mock_load_custom_jinja.called + assert mock_load_custom_templates.called async def test_reload_all( @@ -602,7 +602,7 @@ async def test_reload_all( notify = async_mock_service(hass, "notify", "reload") core_config = async_mock_service(hass, "homeassistant", "reload_core_config") themes = async_mock_service(hass, "frontend", "reload_themes") - jinja = async_mock_service(hass, "homeassistant", "reload_custom_jinja") + jinja = async_mock_service(hass, "homeassistant", "reload_custom_templates") with patch( "homeassistant.config.async_check_ha_config_file", diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 45237a5cbf02..b381775f1e14 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -245,8 +245,8 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: async def test_import(hass: HomeAssistant) -> None: - """Test that imports work from the config/custom_jinja folder.""" - await template.async_load_custom_jinja(hass) + """Test that imports work from the config/custom_templates folder.""" + await template.async_load_custom_templates(hass) assert "test.jinja" in template._get_hass_loader(hass).sources assert "inner/inner_test.jinja" in template._get_hass_loader(hass).sources assert ( @@ -283,7 +283,7 @@ async def test_import(hass: HomeAssistant) -> None: async def test_import_change(hass: HomeAssistant) -> None: """Test that a change in HassLoader results in updated imports.""" - await template.async_load_custom_jinja(hass) + await template.async_load_custom_templates(hass) to_test = template.Template( """ {% import 'test.jinja' as t %} diff --git a/tests/testing_config/custom_jinja/inner/inner_test.jinja b/tests/testing_config/custom_templates/inner/inner_test.jinja similarity index 100% rename from tests/testing_config/custom_jinja/inner/inner_test.jinja rename to tests/testing_config/custom_templates/inner/inner_test.jinja diff --git a/tests/testing_config/custom_jinja/test.jinja b/tests/testing_config/custom_templates/test.jinja similarity index 100% rename from tests/testing_config/custom_jinja/test.jinja rename to tests/testing_config/custom_templates/test.jinja From fc67a147ce15b59c16b07cf419e7eaf5344f7710 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Mar 2023 22:01:31 +0200 Subject: [PATCH 0947/1058] Bumped version to 2023.4.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 1559560f11fa..289f536089a8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -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, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 577ba181401e..e7e82d2ed560 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0.dev0" +version = "2023.4.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From e877fd6682ced68742a701598e3f0c129ef16f26 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Wed, 29 Mar 2023 23:43:54 +0200 Subject: [PATCH 0948/1058] Use auth token in Ezviz (#54663) * Initial commit * Revert "Initial commit" This reverts commit 452027f1a3c1be186cedd4115cea6928917c9467. * Change ezviz to token auth * Bump API version. * Add fix for token expired. Fix options update and unload. * Fix tests (PLATFORM to PLATFORM_BY_TYPE) * Uses and stores token only, added reauth step when token expires. * Add tests MFA code exceptions. * Fix tests. * Remove redundant try/except blocks. * Rebase fixes. * Fix errors in reauth config flow * Implement recommendations * Fix typing error in config_flow * Fix tests after rebase, readd camera check on init * Change to platform setup * Cleanup init. * Test for MFA required under user form * Remove useless if block. * Fix formating after rebase * Fix formating. * No longer stored in the repository --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/ezviz/__init__.py | 137 +++++---- homeassistant/components/ezviz/camera.py | 14 +- homeassistant/components/ezviz/config_flow.py | 286 +++++++++++------- homeassistant/components/ezviz/const.py | 5 +- homeassistant/components/ezviz/coordinator.py | 20 +- homeassistant/components/ezviz/strings.json | 14 +- tests/components/ezviz/__init__.py | 26 +- tests/components/ezviz/conftest.py | 8 +- tests/components/ezviz/test_config_flow.py | 242 +++++++++++++-- 9 files changed, 535 insertions(+), 217 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index fbd49102f3c3..489ff97eb4a6 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -2,26 +2,26 @@ import logging from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_TYPE, - CONF_URL, - CONF_USERNAME, - Platform, -) +from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DATA_COORDINATOR, - DATA_UNDO_UPDATE_LISTENER, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -30,17 +30,22 @@ from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CAMERA, - Platform.SENSOR, - Platform.SWITCH, -] +PLATFORMS_BY_TYPE: dict[str, list] = { + ATTR_TYPE_CAMERA: [], + ATTR_TYPE_CLOUD: [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.SENSOR, + Platform.SWITCH, + ], +} async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up EZVIZ from a config entry.""" hass.data.setdefault(DOMAIN, {}) + sensor_type: str = entry.data[CONF_TYPE] + ezviz_client = None if not entry.options: options = { @@ -50,69 +55,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=options) - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - if hass.data.get(DOMAIN): - # Should only execute on addition of new camera entry. - # Fetch Entry id of main account and reload it. - for item in hass.config_entries.async_entries(): - if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - _LOGGER.info("Reload EZVIZ integration with new camera rtsp entry") - await hass.config_entries.async_reload(item.entry_id) + # Initialize EZVIZ cloud entities + if PLATFORMS_BY_TYPE[sensor_type]: + # Initiate reauth config flow if account token if not present. + if not entry.data.get(CONF_SESSION_ID): + raise ConfigEntryAuthFailed - return True - - try: - ezviz_client = await hass.async_add_executor_job( - _get_ezviz_client_instance, entry + ezviz_client = EzvizClient( + token={ + CONF_SESSION_ID: entry.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: entry.data.get(CONF_RFSESSION_ID), + "api_url": entry.data.get(CONF_URL), + }, + timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - except (InvalidURL, HTTPError, PyEzvizError) as error: - _LOGGER.error("Unable to connect to EZVIZ service: %s", str(error)) - raise ConfigEntryNotReady from error - coordinator = EzvizDataUpdateCoordinator( - hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + try: + await hass.async_add_executor_job(ezviz_client.login) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error + + except (InvalidURL, HTTPError, PyEzvizError) as error: + _LOGGER.error("Unable to connect to Ezviz service: %s", str(error)) + raise ConfigEntryNotReady from error + + coordinator = EzvizDataUpdateCoordinator( + hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. + # Cameras are accessed via local RTSP stream with unique credentials per camera. + # Separate camera entities allow for credential changes per camera. + if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: + for item in hass.config_entries.async_entries(domain=DOMAIN): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + _LOGGER.info("Reload Ezviz main account with camera entry") + await hass.config_entries.async_reload(item.entry_id) + return True + + await hass.config_entries.async_forward_entry_setups( + entry, PLATFORMS_BY_TYPE[sensor_type] ) - await coordinator.async_refresh() - - if not coordinator.last_update_success: - raise ConfigEntryNotReady - - undo_listener = entry.add_update_listener(_async_update_listener) - - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } - 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.""" + sensor_type = entry.data[CONF_TYPE] - if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: - return True - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]() + unload_ok = await hass.config_entries.async_unload_platforms( + entry, PLATFORMS_BY_TYPE[sensor_type] + ) + if sensor_type == ATTR_TYPE_CLOUD and unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient: - """Initialize a new instance of EzvizClientApi.""" - ezviz_client = EzvizClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_URL], - entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ) - ezviz_client.login() - return ezviz_client diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 7901061c0215..0456e7ade9e6 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -34,7 +34,6 @@ from .const import ( DATA_COORDINATOR, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_RTSP_PORT, DIR_DOWN, DIR_LEFT, DIR_RIGHT, @@ -70,24 +69,17 @@ async def async_setup_entry( if item.unique_id == camera and item.source != SOURCE_IGNORE ] - # There seem to be a bug related to localRtspPort in EZVIZ API. - local_rtsp_port = ( - value["local_rtsp_port"] - if value["local_rtsp_port"] != 0 - else DEFAULT_RTSP_PORT - ) - if camera_rtsp_entry: ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS] camera_username = camera_rtsp_entry[0].data[CONF_USERNAME] camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD] - camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}" + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{value['local_rtsp_port']}{ffmpeg_arguments}" _LOGGER.debug( "Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s", camera, value["local_ip"], - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) @@ -123,7 +115,7 @@ async def async_setup_entry( camera_username, camera_password, camera_rtsp_stream, - local_rtsp_port, + value["local_rtsp_port"], ffmpeg_arguments, ) ) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 4c8b1418fa51..77598ad6a1c7 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,12 +1,14 @@ -"""Config flow for ezviz.""" +"""Config flow for EZVIZ.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from pyezviz.client import EzvizClient from pyezviz.exceptions import ( AuthTestResultFailed, - HTTPError, + EzvizAuthVerificationCode, InvalidHost, InvalidURL, PyEzvizError, @@ -25,12 +27,15 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_CAMERA_USERNAME, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, @@ -40,23 +45,37 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +DEFAULT_OPTIONS = { + CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, + CONF_TIMEOUT: DEFAULT_TIMEOUT, +} -def _get_ezviz_client_instance(data): - """Initialize a new instance of EzvizClientApi.""" +def _validate_and_create_auth(data: dict) -> dict[str, Any]: + """Try to login to EZVIZ cloud account and return token.""" + # Verify cloud credentials by attempting a login request with username and password. + # Return login token. ezviz_client = EzvizClient( data[CONF_USERNAME], data[CONF_PASSWORD], - data.get(CONF_URL, EU_URL), + data[CONF_URL], data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), ) - ezviz_client.login() - return ezviz_client + ezviz_token = ezviz_client.login() + + auth_data = { + CONF_SESSION_ID: ezviz_token[CONF_SESSION_ID], + CONF_RFSESSION_ID: ezviz_token[CONF_RFSESSION_ID], + CONF_URL: ezviz_token["api_url"], + CONF_TYPE: ATTR_TYPE_CLOUD, + } + + return auth_data -def _test_camera_rtsp_creds(data): +def _test_camera_rtsp_creds(data: dict) -> None: """Try DESCRIBE on RTSP camera with credentials.""" test_rtsp = TestRTSPAuth( @@ -71,89 +90,43 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _validate_and_create_auth(self, data): - """Try to login to ezviz cloud account and create entry if successful.""" - await self.async_set_unique_id(data[CONF_USERNAME]) - self._abort_if_unique_id_configured() - - # Verify cloud credentials by attempting a login request. - try: - await self.hass.async_add_executor_job(_get_ezviz_client_instance, data) - - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - auth_data = { - CONF_USERNAME: data[CONF_USERNAME], - CONF_PASSWORD: data[CONF_PASSWORD], - CONF_URL: data.get(CONF_URL, EU_URL), - CONF_TYPE: ATTR_TYPE_CLOUD, - } - - return self.async_create_entry(title=data[CONF_USERNAME], data=auth_data) - - async def _validate_and_create_camera_rtsp(self, data): + async def _validate_and_create_camera_rtsp(self, data: dict) -> FlowResult: """Try DESCRIBE on RTSP camera with credentials.""" # Get EZVIZ cloud credentials from config entry - ezviz_client_creds = { - CONF_USERNAME: None, - CONF_PASSWORD: None, - CONF_URL: None, + ezviz_token = { + CONF_SESSION_ID: None, + CONF_RFSESSION_ID: None, + "api_url": None, } + ezviz_timeout = DEFAULT_TIMEOUT for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - ezviz_client_creds = { - CONF_USERNAME: item.data.get(CONF_USERNAME), - CONF_PASSWORD: item.data.get(CONF_PASSWORD), - CONF_URL: item.data.get(CONF_URL), + ezviz_token = { + CONF_SESSION_ID: item.data.get(CONF_SESSION_ID), + CONF_RFSESSION_ID: item.data.get(CONF_RFSESSION_ID), + "api_url": item.data.get(CONF_URL), } + ezviz_timeout = item.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) # Abort flow if user removed cloud account before adding camera. - if ezviz_client_creds[CONF_USERNAME] is None: + if ezviz_token.get(CONF_SESSION_ID) is None: return self.async_abort(reason="ezviz_cloud_account_missing") + ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout) + # We need to wake hibernating cameras. # First create EZVIZ API instance. - try: - ezviz_client = await self.hass.async_add_executor_job( - _get_ezviz_client_instance, ezviz_client_creds - ) + await self.hass.async_add_executor_job(ezviz_client.login) - except InvalidURL as err: - raise InvalidURL from err - - except HTTPError as err: - raise InvalidHost from err - - except PyEzvizError as err: - raise PyEzvizError from err - - # Secondly try to wake hibernating camera. - try: - await self.hass.async_add_executor_job( - ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] - ) - - except HTTPError as err: - raise InvalidHost from err + # Secondly try to wake hybernating camera. + await self.hass.async_add_executor_job( + ezviz_client.get_detection_sensibility, data[ATTR_SERIAL] + ) # Thirdly attempts an authenticated RTSP DESCRIBE request. - try: - await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) - - except InvalidHost as err: - raise InvalidHost from err - - except AuthTestResultFailed as err: - raise AuthTestResultFailed from err + await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data) return self.async_create_entry( title=data[ATTR_SERIAL], @@ -162,6 +135,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): CONF_PASSWORD: data[CONF_PASSWORD], CONF_TYPE: ATTR_TYPE_CAMERA, }, + options=DEFAULT_OPTIONS, ) @staticmethod @@ -170,18 +144,24 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return EzvizOptionsFlowHandler(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 a flow initiated by the user.""" - # Check if ezviz cloud account is present in entry config, + # Check if EZVIZ cloud account is present in entry config, # abort if already configured. for item in self._async_current_entries(): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: return self.async_abort(reason="already_configured_account") errors = {} + auth_data = {} if user_input is not None: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + if user_input[CONF_URL] == CONF_CUSTOMIZE: self.context["data"] = { CONF_USERNAME: user_input[CONF_USERNAME], @@ -189,11 +169,10 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): } return await self.async_step_user_custom_url() - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -201,6 +180,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -208,6 +190,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema = vol.Schema( { vol.Required(CONF_USERNAME): str, @@ -222,20 +211,21 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_user_custom_url(self, user_input=None): + async def async_step_user_custom_url( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user for custom region url.""" - errors = {} + auth_data = {} if user_input is not None: user_input[CONF_USERNAME] = self.context["data"][CONF_USERNAME] user_input[CONF_PASSWORD] = self.context["data"][CONF_PASSWORD] - if CONF_TIMEOUT not in user_input: - user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT - try: - return await self._validate_and_create_auth(user_input) + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) except InvalidURL: errors["base"] = "invalid_host" @@ -243,6 +233,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except InvalidHost: errors["base"] = "cannot_connect" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except PyEzvizError: errors["base"] = "invalid_auth" @@ -250,6 +243,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=auth_data, + options=DEFAULT_OPTIONS, + ) + data_schema_custom_url = vol.Schema( { vol.Required(CONF_URL, default=EU_URL): str, @@ -260,18 +260,22 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user_custom_url", data_schema=data_schema_custom_url, errors=errors ) - async def async_step_integration_discovery(self, discovery_info): + async def async_step_integration_discovery( + self, discovery_info: dict[str, Any] + ) -> FlowResult: """Handle a flow for discovered camera without rtsp config entry.""" await self.async_set_unique_id(discovery_info[ATTR_SERIAL]) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = {"serial": self.unique_id} + self.context["title_placeholders"] = {ATTR_SERIAL: self.unique_id} self.context["data"] = {CONF_IP_ADDRESS: discovery_info[CONF_IP_ADDRESS]} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm and create entry from discovery step.""" errors = {} @@ -284,6 +288,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): except (InvalidHost, InvalidURL): errors["base"] = "invalid_host" + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + except (PyEzvizError, AuthTestResultFailed): errors["base"] = "invalid_auth" @@ -303,11 +310,76 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=discovered_camera_schema, errors=errors, description_placeholders={ - "serial": self.unique_id, + ATTR_SERIAL: self.unique_id, CONF_IP_ADDRESS: self.context["data"][CONF_IP_ADDRESS], }, ) + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle a flow for reauthentication with password.""" + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Confirm flow for reauthentication with password.""" + auth_data = {} + errors = {} + entry = None + + for item in self._async_current_entries(): + if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: + self.context["title_placeholders"] = {ATTR_SERIAL: item.title} + entry = await self.async_set_unique_id(item.title) + + if not entry: + return self.async_abort(reason="ezviz_cloud_account_missing") + + if user_input is not None: + user_input[CONF_URL] = entry.data[CONF_URL] + + try: + auth_data = await self.hass.async_add_executor_job( + _validate_and_create_auth, user_input + ) + + except (InvalidHost, InvalidURL): + errors["base"] = "invalid_host" + + except EzvizAuthVerificationCode: + errors["base"] = "mfa_required" + + except (PyEzvizError, AuthTestResultFailed): + errors["base"] = "invalid_auth" + + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + else: + self.hass.config_entries.async_update_entry( + entry, + data=auth_data, + ) + + await self.hass.config_entries.async_reload(entry.entry_id) + + return self.async_abort(reason="reauth_successful") + + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME, default=entry.title): vol.In([entry.title]), + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=errors, + ) + class EzvizOptionsFlowHandler(OptionsFlow): """Handle EZVIZ client options.""" @@ -316,22 +388,28 @@ class EzvizOptionsFlowHandler(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: """Manage EZVIZ options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) - options = { - vol.Optional( - CONF_TIMEOUT, - default=self.config_entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), - ): int, - vol.Optional( - CONF_FFMPEG_ARGUMENTS, - default=self.config_entry.options.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ), - ): str, - } + options = vol.Schema( + { + vol.Optional( + CONF_TIMEOUT, + default=self.config_entry.options.get( + CONF_TIMEOUT, DEFAULT_TIMEOUT + ), + ): int, + vol.Optional( + CONF_FFMPEG_ARGUMENTS, + default=self.config_entry.options.get( + CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS + ), + ): str, + } + ) - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) + return self.async_show_form(step_id="init", data_schema=options) diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index b9183772b6c3..d052a4b82166 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -10,6 +10,9 @@ ATTR_HOME = "HOME_MODE" ATTR_AWAY = "AWAY_MODE" ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT" ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT" +CONF_SESSION_ID = "session_id" +CONF_RFSESSION_ID = "rf_session_id" +CONF_EZVIZ_ACCOUNT = "ezviz_account" # Services data DIR_UP = "up" @@ -33,10 +36,8 @@ SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" -DEFAULT_RTSP_PORT = 554 DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" # Data DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index cc4537bb9b94..ba8ed336a51c 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -4,9 +4,16 @@ import logging from async_timeout import timeout from pyezviz.client import EzvizClient -from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from pyezviz.exceptions import ( + EzvizAuthTokenExpired, + EzvizAuthVerificationCode, + HTTPError, + InvalidURL, + PyEzvizError, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -27,15 +34,16 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - def _update_data(self) -> dict: - """Fetch data from EZVIZ via camera load function.""" - return self.ezviz_client.load_cameras() - async def _async_update_data(self) -> dict: """Fetch data from EZVIZ.""" try: async with timeout(self._api_timeout): - return await self.hass.async_add_executor_job(self._update_data) + return await self.hass.async_add_executor_job( + self.ezviz_client.load_cameras + ) + + except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error: + raise ConfigEntryAuthFailed from error except (InvalidURL, HTTPError, PyEzvizError) as error: raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 91fa32ad9b2f..5e258e427057 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -26,17 +26,27 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Enter credentials to reauthenticate to ezviz cloud account", + "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%]", - "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "mfa_required": "2FA enabled on account, please disable and retry" }, "abort": { "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account" + "ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/ezviz/__init__.py b/tests/components/ezviz/__init__.py index 64dcbfc26ebc..768fc30cc81c 100644 --- a/tests/components/ezviz/__init__.py +++ b/tests/components/ezviz/__init__.py @@ -3,8 +3,11 @@ from unittest.mock import patch from homeassistant.components.ezviz.const import ( ATTR_SERIAL, + ATTR_TYPE_CAMERA, ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, + CONF_RFSESSION_ID, + CONF_SESSION_ID, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, @@ -22,8 +25,8 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry ENTRY_CONFIG = { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", + CONF_SESSION_ID: "test-username", + CONF_RFSESSION_ID: "test-password", CONF_URL: "apiieu.ezvizlife.com", CONF_TYPE: ATTR_TYPE_CLOUD, } @@ -46,6 +49,18 @@ USER_INPUT = { CONF_TYPE: ATTR_TYPE_CLOUD, } +USER_INPUT_CAMERA_VALIDATE = { + ATTR_SERIAL: "C666666", + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", +} + +USER_INPUT_CAMERA = { + CONF_PASSWORD: "test-password", + CONF_USERNAME: "test-username", + CONF_TYPE: ATTR_TYPE_CAMERA, +} + DISCOVERY_INFO = { ATTR_SERIAL: "C666666", CONF_USERNAME: None, @@ -59,6 +74,13 @@ TEST = { CONF_IP_ADDRESS: "127.0.0.1", } +API_LOGIN_RETURN_VALIDATE = { + CONF_SESSION_ID: "fake_token", + CONF_RFSESSION_ID: "fake_rf_token", + CONF_URL: "apiieu.ezvizlife.com", + CONF_TYPE: ATTR_TYPE_CLOUD, +} + def _patch_async_setup_entry(return_value=True): return patch( diff --git a/tests/components/ezviz/conftest.py b/tests/components/ezviz/conftest.py index 76b962250b7f..e89e375fb5ec 100644 --- a/tests/components/ezviz/conftest.py +++ b/tests/components/ezviz/conftest.py @@ -5,6 +5,12 @@ from pyezviz import EzvizClient from pyezviz.test_cam_rtsp import TestRTSPAuth import pytest +ezviz_login_token_return = { + "session_id": "fake_token", + "rf_session_id": "fake_rf_token", + "api_url": "apiieu.ezvizlife.com", +} + @pytest.fixture(autouse=True) def mock_ffmpeg(hass): @@ -42,7 +48,7 @@ def ezviz_config_flow(hass): "1", ) - instance.login = MagicMock(return_value=True) + instance.login = MagicMock(return_value=ezviz_login_token_return) instance.get_detection_sensibility = MagicMock(return_value=True) yield mock_ezviz diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 624827220c42..939bb92bcc0f 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch from pyezviz.exceptions import ( AuthTestResultFailed, + EzvizAuthVerificationCode, HTTPError, InvalidHost, InvalidURL, @@ -12,13 +13,16 @@ from pyezviz.exceptions import ( from homeassistant.components.ezviz.const import ( ATTR_SERIAL, ATTR_TYPE_CAMERA, - ATTR_TYPE_CLOUD, CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT, DOMAIN, ) -from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER +from homeassistant.config_entries import ( + SOURCE_INTEGRATION_DISCOVERY, + SOURCE_REAUTH, + SOURCE_USER, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -32,8 +36,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + API_LOGIN_RETURN_VALIDATE, DISCOVERY_INFO, - USER_INPUT, USER_INPUT_VALIDATE, _patch_async_setup_entry, init_integration, @@ -59,7 +63,7 @@ async def test_user_form(hass: HomeAssistant, ezviz_config_flow) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" - assert result["data"] == {**USER_INPUT} + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} assert len(mock_setup_entry.mock_calls) == 1 @@ -78,7 +82,11 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass", CONF_URL: "customize"}, + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_URL: CONF_CUSTOMIZE, + }, ) assert result["type"] == FlowResultType.FORM @@ -90,21 +98,58 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: result["flow_id"], {CONF_URL: "test-user"}, ) + await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_PASSWORD: "test-pass", - CONF_TYPE: ATTR_TYPE_CLOUD, - CONF_URL: "test-user", - CONF_USERNAME: "test-user", - } + assert result["data"] == API_LOGIN_RETURN_VALIDATE assert len(mock_setup_entry.mock_calls) == 1 -async def test_step_discovery_abort_if_cloud_account_missing( - hass: HomeAssistant, -) -> None: +async def test_async_step_reauth(hass, ezviz_config_flow): + """Test the reauth step.""" + + 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["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_step_discovery_abort_if_cloud_account_missing(hass): """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -127,11 +172,21 @@ async def test_step_discovery_abort_if_cloud_account_missing( assert result["reason"] == "ezviz_cloud_account_missing" +async def test_step_reauth_abort_if_cloud_account_missing(hass): + """Test reauth and confirm step, abort if cloud account was removed.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "ezviz_cloud_account_missing" + + async def test_async_step_integration_discovery( - hass: HomeAssistant, ezviz_config_flow, ezviz_test_rtsp_config_flow -) -> None: + hass, ezviz_config_flow, ezviz_test_rtsp_config_flow +): """Test discovery and confirm step.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -189,11 +244,14 @@ async def test_options_flow(hass: HomeAssistant) -> None: async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> None: """Test we handle exception on user form.""" - ezviz_config_flow.side_effect = PyEzvizError - 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["errors"] == {} + + ezviz_config_flow.side_effect = PyEzvizError result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -215,6 +273,17 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["step_id"] == "user" assert result["errors"] == {"base": "invalid_host"} + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "mfa_required"} + ezviz_config_flow.side_effect = HTTPError result = await hass.config_entries.flow.async_configure( @@ -224,7 +293,7 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} ezviz_config_flow.side_effect = Exception @@ -242,7 +311,7 @@ async def test_discover_exception_step1( ezviz_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -295,7 +364,21 @@ async def test_discover_exception_step1( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" - assert result["errors"] == {"base": "invalid_host"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-user", + CONF_PASSWORD: "test-pass", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "confirm" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -317,7 +400,7 @@ async def test_discover_exception_step3( ezviz_test_rtsp_config_flow, ) -> None: """Test we handle unexpected exception on discovery.""" - with patch("homeassistant.components.ezviz.PLATFORMS", []): + with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) result = await hass.config_entries.flow.async_init( @@ -423,7 +506,18 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user_custom_url" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_URL: "test-user"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user_custom_url" + assert result["errors"] == {"base": "mfa_required"} ezviz_config_flow.side_effect = Exception @@ -434,3 +528,103 @@ async def test_user_custom_url_exception( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "unknown" + + +async def test_async_step_reauth_exception(hass, ezviz_config_flow): + """Test the reauth step exceptions.""" + + 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["errors"] == {} + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT_VALIDATE, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == {**API_LOGIN_RETURN_VALIDATE} + + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + ezviz_config_flow.side_effect = InvalidURL() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = InvalidHost() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_host"} + + ezviz_config_flow.side_effect = EzvizAuthVerificationCode() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "mfa_required"} + + ezviz_config_flow.side_effect = PyEzvizError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + ezviz_config_flow.side_effect = Exception() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From 9d116799d603ffaab8b2942c3b9cd915c181f198 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Thu, 30 Mar 2023 08:05:24 +0200 Subject: [PATCH 0949/1058] Add missing strings in frontier_silicon (#90446) Improve confirm message for ssdp flow --- homeassistant/components/frontier_silicon/config_flow.py | 4 +++- homeassistant/components/frontier_silicon/strings.json | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index a054bd2b30e4..0ccc61e99c10 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -188,7 +188,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_create_entry() self._set_confirm_only() - return self.async_show_form(step_id="confirm") + return self.async_show_form( + step_id="confirm", description_placeholders={"name": self._name} + ) async def async_step_device_config( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 3a0a504761b8..a7c3f3e439cb 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -13,6 +13,9 @@ "data": { "pin": "[%key:common::config_flow::data::pin%]" } + }, + "confirm": { + "description": "Do you want to set up {name}?" } }, "error": { From baccbd98c7257216d7082e54be451727dac32f7a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Mar 2023 23:26:05 +0200 Subject: [PATCH 0950/1058] Bump reolink-aio to 0.5.8 (#90467) --- 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 95b180fc164c..79fc15c571de 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.5.7"] + "requirements": ["reolink-aio==0.5.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index d51947b81cfb..22e80fced471 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 514e653d3464..fa7b257f8e51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.7 +reolink-aio==0.5.8 # homeassistant.components.python_script restrictedpython==6.0 From b5811ad1c2fa31d05111ebc0fbcba8c6b257e331 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 29 Mar 2023 23:25:33 +0200 Subject: [PATCH 0951/1058] Add entity name translations for devolo Home Network (#90471) --- .../devolo_home_network/binary_sensor.py | 1 - .../components/devolo_home_network/entity.py | 1 + .../components/devolo_home_network/sensor.py | 3 --- .../devolo_home_network/strings.json | 26 +++++++++++++++++++ .../components/devolo_home_network/switch.py | 2 -- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index e927ea933381..809dc9086be5 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -53,7 +53,6 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:router-network", - name="Connected to router", value_func=_is_connected_to_router, ), } diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a26d8dce8f6f..8b665d7bf024 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -57,4 +57,5 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): name=entry.title, sw_version=device.firmware_version, ) + self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2c2637c2f8dd..aeeab2ce89b0 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -54,7 +54,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:lan", - name="Connected PLC devices", value_func=lambda data: len( {device.mac_address_from for device in data.data_rates} ), @@ -62,7 +61,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( key=CONNECTED_WIFI_CLIENTS, icon="mdi:wifi", - name="Connected Wifi clients", state_class=SensorStateClass.MEASUREMENT, value_func=len, ), @@ -71,7 +69,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, icon="mdi:wifi-marker", - name="Neighboring Wifi networks", value_func=len, ), } diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 6c320710a1ba..3472886cd5b0 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -27,5 +27,31 @@ "home_control": "The devolo Home Control Central Unit does not work with this integration.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "connected_to_router": { + "name": "Connected to router" + } + }, + "sensor": { + "connected_plc_devices": { + "name": "Connected PLC devices" + }, + "connected_wifi_clients": { + "name": "Connected Wifi clients" + }, + "neighboring_wifi_networks": { + "name": "Neighboring Wifi networks" + } + }, + "switch": { + "switch_guest_wifi": { + "name": "Enable guest Wifi" + }, + "switch_leds": { + "name": "Enable LEDs" + } + } } } diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index fa2447985dad..6f387fdf05f4 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -42,7 +42,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet]( key=SWITCH_GUEST_WIFI, icon="mdi:wifi", - name="Enable guest Wifi", is_on_func=lambda data: data.enabled is True, turn_on_func=lambda device: device.device.async_set_wifi_guest_access(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_wifi_guest_access(False), # type: ignore[union-attr] @@ -51,7 +50,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { key=SWITCH_LEDS, entity_category=EntityCategory.CONFIG, icon="mdi:led-off", - name="Enable LEDs", is_on_func=bool, turn_on_func=lambda device: device.device.async_set_led_setting(True), # type: ignore[union-attr] turn_off_func=lambda device: device.device.async_set_led_setting(False), # type: ignore[union-attr] From 9f3c0fa9271c1e405d850c18d0332da27f55c6fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:24:47 -1000 Subject: [PATCH 0952/1058] Bump yalexs-ble to 2.1.14 (#90474) changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.13...v2.1.14 reduces ble traffic (fixes a bug were we were checking when we did not need to be) --- 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 07ecc2a1bec7..84b5ae7e2052 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.2.7", "yalexs-ble==2.1.13"] + "requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 7c45f309e637..f1ec6ba14c46 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.1.13"] + "requirements": ["yalexs-ble==2.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22e80fced471..cae40bd2c6d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa7b257f8e51..75b7dfeb3be4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1911,7 +1911,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.13 +yalexs-ble==2.1.14 # homeassistant.components.august yalexs==1.2.7 From 02f108498cf729e944038affa1a9205243853df9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 30 Mar 2023 10:21:11 +0200 Subject: [PATCH 0953/1058] Add missing strings to sensor integration (#90475) * Add missing strings to sensor integration * Enumeration * Apply suggestion Co-authored-by: Franck Nijhof --------- Co-authored-by: Franck Nijhof --- homeassistant/components/sensor/strings.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 5b34c5a28e32..16e0da0d5182 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -160,6 +160,9 @@ "energy_storage": { "name": "Stored energy" }, + "enum": { + "name": "[%key:component::sensor::title%]" + }, "frequency": { "name": "Frequency" }, @@ -235,6 +238,9 @@ "temperature": { "name": "Temperature" }, + "timestamp": { + "name": "Timestamp" + }, "volatile_organic_compounds": { "name": "VOCs" }, From 30af4c769e723fda0f7511e34a88a3ab0bd7ba59 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:24:26 -0400 Subject: [PATCH 0954/1058] Correctly load ZHA settings from API when integration is not running (#90476) Correctly load settings from the zigpy database when ZHA is not running --- homeassistant/components/zha/api.py | 23 ++++++++--------------- tests/components/zha/test_api.py | 5 ++++- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index d34dd2338e34..652f19d24bac 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -18,8 +18,6 @@ from .core.const import ( from .core.gateway import ZHAGateway if TYPE_CHECKING: - from zigpy.application import ControllerApplication - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -49,21 +47,17 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: return entries[0] -def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup: - """Wrap the ZHA network settings into a `NetworkBackup`.""" +def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: + """Get the network settings for the currently active ZHA network.""" + zha_gateway: ZHAGateway = _get_gateway(hass) + app = zha_gateway.application_controller + return NetworkBackup( node_info=app.state.node_info, network_info=app.state.network_info, ) -def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: - """Get the network settings for the currently active ZHA network.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - - return _wrap_network_settings(zha_gateway.application_controller) - - async def async_get_last_network_settings( hass: HomeAssistant, config_entry: ConfigEntry | None = None ) -> NetworkBackup | None: @@ -79,13 +73,12 @@ async def async_get_last_network_settings( try: await app._load_db() # pylint: disable=protected-access - settings = _wrap_network_settings(app) + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None finally: await app.shutdown() - if settings.network_info.channel == 0: - return None - return settings diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c60790998048..59daf2179b6a 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +import zigpy.backups import zigpy.state from homeassistant.components import zha @@ -36,7 +37,9 @@ async def test_async_get_network_settings_inactive( gateway = api._get_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) - zigpy_app_controller.state.network_info.channel = 20 + backup = zigpy.backups.NetworkBackup() + backup.network_info.channel = 20 + zigpy_app_controller.backups.backups.append(backup) with patch( "bellows.zigbee.application.ControllerApplication.__new__", From 2a627e63f1c5ac837f53a052278e6df90a0620ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Mar 2023 11:26:28 -1000 Subject: [PATCH 0955/1058] Fix filesize doing blocking I/O in the event loop (#90479) Fix filesize doing I/O in the event loop --- homeassistant/components/filesize/__init__.py | 19 +++++++------------ .../components/filesize/config_flow.py | 4 +++- homeassistant/core.py | 6 +++++- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 9e08615d4ab8..73f060e79b70 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -11,24 +11,19 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS -def check_path(path: pathlib.Path) -> bool: - """Check path.""" - return path.exists() and path.is_file() - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up from a config entry.""" - - path = entry.data[CONF_FILE_PATH] +def _check_path(hass: HomeAssistant, path: str) -> None: + """Check if path is valid and allowed.""" get_path = pathlib.Path(path) - - check_file = await hass.async_add_executor_job(check_path, get_path) - if not check_file: + if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/config_flow.py b/homeassistant/components/filesize/config_flow.py index 3f58e636b0e6..8633e6ec466f 100644 --- a/homeassistant/components/filesize/config_flow.py +++ b/homeassistant/components/filesize/config_flow.py @@ -49,7 +49,9 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - full_path = validate_path(self.hass, user_input[CONF_FILE_PATH]) + full_path = await self.hass.async_add_executor_job( + validate_path, self.hass, user_input[CONF_FILE_PATH] + ) except NotValidError: errors["base"] = "not_valid" except NotAllowedError: diff --git a/homeassistant/core.py b/homeassistant/core.py index 900355d4a5d3..78ceb620e53f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1950,7 +1950,11 @@ class Config: ) def is_allowed_path(self, path: str) -> bool: - """Check if the path is valid for access from outside.""" + """Check if the path is valid for access from outside. + + This function does blocking I/O and should not be called from the event loop. + Use hass.async_add_executor_job to schedule it on the executor. + """ assert path is not None thepath = pathlib.Path(path) From b83cb5d1b1c3be759e84f08f8faa3ac5ce1f8ccd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:21:45 -0400 Subject: [PATCH 0956/1058] OpenAI to rely on built-in `areas` variable (#90481) --- homeassistant/components/openai_conversation/__init__.py | 3 +-- homeassistant/components/openai_conversation/const.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 3e67d4e27dac..6f76142106ad 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import area_registry as ar, intent, template +from homeassistant.helpers import intent, template from homeassistant.util import ulid from .const import ( @@ -138,7 +138,6 @@ class OpenAIAgent(conversation.AbstractConversationAgent): return template.Template(raw_prompt, self.hass).async_render( { "ha_name": self.hass.config.location_name, - "areas": list(ar.async_get(self.hass).areas.values()), }, parse_result=False, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 88289eb90b04..46f8603c5f16 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -5,13 +5,13 @@ CONF_PROMPT = "prompt" DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. An overview of the areas and the devices in this smart home: -{%- for area in areas %} +{%- for area in areas() %} {%- set area_info = namespace(printed=false) %} - {%- for device in area_devices(area.name) -%} + {%- for device in area_devices(area) -%} {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} {%- if not area_info.printed %} -{{ area.name }}: +{{ area_name(area) }}: {%- set area_info.printed = true %} {%- endif %} - {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} From 2157a4d0fcb95ea43c688b84db0a7aa8335a149c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Mar 2023 15:16:27 +0200 Subject: [PATCH 0957/1058] Include channel in response to WS thread/list_datasets (#90493) --- .../components/thread/dataset_store.py | 10 +++++++ .../components/thread/websocket_api.py | 1 + tests/components/thread/test_dataset_store.py | 29 +++++++++++++++++++ tests/components/thread/test_websocket_api.py | 3 ++ 4 files changed, 43 insertions(+) diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index ea5a16f90cd6..786ea55b34fb 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 contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property @@ -35,6 +36,15 @@ class DatasetEntry: created: datetime = dataclasses.field(default_factory=dt_util.utcnow) id: str = dataclasses.field(default_factory=ulid_util.ulid) + @property + def channel(self) -> int | None: + """Return channel as an integer.""" + if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + return None + with suppress(ValueError): + return int(channel, 16) + return None + @cached_property def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: """Return the dataset in dict format.""" diff --git a/homeassistant/components/thread/websocket_api.py b/homeassistant/components/thread/websocket_api.py index 9f9bc3455a8e..aca0d5e5d966 100644 --- a/homeassistant/components/thread/websocket_api.py +++ b/homeassistant/components/thread/websocket_api.py @@ -144,6 +144,7 @@ async def ws_list_datasets( for dataset in store.datasets.values(): result.append( { + "channel": dataset.channel, "created": dataset.created, "dataset_id": dataset.id, "extended_pan_id": dataset.extended_pan_id, diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 581329e860a3..212db0de06f1 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -19,6 +19,18 @@ DATASET_1_REORDERED = ( "10445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F801021234" ) +DATASET_1_BAD_CHANNEL = ( + "0E080000000000010000000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_CHANNEL = ( + "0E08000000000001000035060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + async def test_add_invalid_dataset(hass: HomeAssistant) -> None: """Test adding an invalid dataset.""" @@ -109,6 +121,8 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: {"source": "Google", "tlv": DATASET_1}, {"source": "Multipan", "tlv": DATASET_2}, {"source": "🎅", "tlv": DATASET_3}, + {"source": "test1", "tlv": DATASET_1_BAD_CHANNEL}, + {"source": "test2", "tlv": DATASET_1_NO_CHANNEL}, ] for dataset in datasets: @@ -122,25 +136,40 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset_2 = dataset if dataset.source == "🎅": dataset_3 = dataset + if dataset.source == "test1": + dataset_4 = dataset + if dataset.source == "test2": + dataset_5 = dataset dataset = store.async_get(dataset_1.id) assert dataset == dataset_1 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "OpenThreadDemo" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_2.id) assert dataset == dataset_2 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "HomeAssistant!" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_3.id) assert dataset == dataset_3 + assert dataset.channel == 15 assert dataset.extended_pan_id == "1111111122222222" assert dataset.network_name == "~🐣🐥🐤~" assert dataset.pan_id == "1234" + dataset = store.async_get(dataset_4.id) + assert dataset == dataset_4 + assert dataset.channel is None + + dataset = store.async_get(dataset_5.id) + assert dataset == dataset_5 + assert dataset.channel is None + async def test_load_datasets(hass: HomeAssistant) -> None: """Make sure that we can load/save data correctly.""" diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index c2e9e5f59340..c7bdd78188d1 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -153,6 +153,7 @@ async def test_list_get_dataset( assert msg["result"] == { "datasets": [ { + "channel": 15, "created": dataset_1.created.isoformat(), "dataset_id": dataset_1.id, "extended_pan_id": "1111111122222222", @@ -162,6 +163,7 @@ async def test_list_get_dataset( "source": "Google", }, { + "channel": 15, "created": dataset_2.created.isoformat(), "dataset_id": dataset_2.id, "extended_pan_id": "1111111122222222", @@ -171,6 +173,7 @@ async def test_list_get_dataset( "source": "Multipan", }, { + "channel": 15, "created": dataset_3.created.isoformat(), "dataset_id": dataset_3.id, "extended_pan_id": "1111111122222222", From 01734c0dab46a55f0bd6f001320fb08077c1b36a Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 30 Mar 2023 09:14:58 -0400 Subject: [PATCH 0958/1058] Fix for is_hidden_entity when using it in select, selectattr, reject, and rejectattr (#90512) fix --- homeassistant/helpers/template.py | 15 +++++++++++---- tests/helpers/test_template.py | 5 +++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 481a59cee858..36e0a597b87a 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2285,9 +2285,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["area_devices"] = hassfunction(area_devices) self.filters["area_devices"] = pass_context(self.globals["area_devices"]) - self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_context(self.globals["is_hidden_entity"]) - self.globals["integration_entities"] = hassfunction(integration_entities) self.filters["integration_entities"] = pass_context( self.globals["integration_entities"] @@ -2308,6 +2305,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "closest", "distance", "expand", + "is_hidden_entity", "is_state", "is_state_attr", "state_attr", @@ -2331,7 +2329,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "area_name", "has_value", ] - hass_tests = ["has_value"] + hass_tests = [ + "has_value", + "is_hidden_entity", + "is_state", + "is_state_attr", + ] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: @@ -2345,6 +2348,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["closest"] = hassfunction(closest) self.filters["closest"] = pass_context(hassfunction(closest_filter)) self.globals["distance"] = hassfunction(distance) + self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) + self.tests["is_hidden_entity"] = pass_eval_context( + self.globals["is_hidden_entity"] + ) self.globals["is_state"] = hassfunction(is_state) self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) self.globals["is_state_attr"] = hassfunction(is_state_attr) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b381775f1e14..f185191d1bfd 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1463,6 +1463,11 @@ def test_is_hidden_entity( hass, ).async_render() + assert not template.Template( + f"{{{{ ['{visible_entity.entity_id}'] | select('is_hidden_entity') | first }}}}", + hass, + ).async_render() + def test_is_state(hass: HomeAssistant) -> None: """Test is_state method.""" From 576780be74c9a0e4ac2ad2e347afb59a043dd546 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 09:23:13 -0400 Subject: [PATCH 0959/1058] Unregister webhook when registering webhook with nuki fials (#90514) --- homeassistant/components/nuki/__init__.py | 43 +++++++++++++---------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 74245d30d4a7..8a7985fe28c3 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -25,6 +25,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.network import get_url from homeassistant.helpers.update_coordinator import ( @@ -146,23 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True ) - async def _stop_nuki(_: Event): - """Stop and remove the Nuki webhook.""" - webhook.async_unregister(hass, entry.entry_id) - try: - async with async_timeout.timeout(10): - await hass.async_add_executor_job( - _remove_webhook, bridge, entry.entry_id - ) - except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err - except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) - ) - webhook_url = webhook.async_generate_path(entry.entry_id) hass_url = get_url( hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False @@ -174,9 +158,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _register_webhook, bridge, entry.entry_id, url ) except InvalidCredentialsException as err: - raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Invalid credentials for Bridge: {err}") from err except RequestException as err: - raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + webhook.async_unregister(hass, entry.entry_id) + raise ConfigEntryNotReady(f"Error communicating with Bridge: {err}") from err + + async def _stop_nuki(_: Event): + """Stop and remove the Nuki webhook.""" + webhook.async_unregister(hass, entry.entry_id) + try: + async with async_timeout.timeout(10): + await hass.async_add_executor_job( + _remove_webhook, bridge, entry.entry_id + ) + except InvalidCredentialsException as err: + _LOGGER.error( + "Error unregistering webhook, invalid credentials for bridge: %s", err + ) + except RequestException as err: + _LOGGER.error("Error communicating with bridge: %s", err) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki) + ) coordinator = NukiCoordinator(hass, bridge, locks, openers) From 4a319c73ab70b5a3079df00951a9e3268a0fd2b2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Mar 2023 16:38:35 +0200 Subject: [PATCH 0960/1058] Add a device to the sun (#90517) --- homeassistant/components/sun/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 527ccc4069fa..8a253566e20c 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -15,6 +15,8 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -126,6 +128,12 @@ class SunSensor(SensorEntity): self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.sun = sun + self._attr_device_info = DeviceInfo( + name="Sun", + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" From 705e68be9e3253f4740433c00b0f728f1c179f94 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 10:40:19 -0400 Subject: [PATCH 0961/1058] Bumped version to 2023.4.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 289f536089a8..fba1d6545948 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -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, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index e7e82d2ed560..73d680092f80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b0" +version = "2023.4.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 38aff23be50f0f645fdfcd15e6eec2af1f88cdae Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Thu, 30 Mar 2023 17:15:12 +0200 Subject: [PATCH 0962/1058] Migrate old ZHA IasZone sensor state to zigpy cache (#90508) * Migrate old ZHA IasZone sensor state to zigpy cache * Use correct type for ZoneStatus * Test that migration happens * Test that migration only happens once * Fix parametrize --- homeassistant/components/zha/binary_sensor.py | 35 ++++++- tests/components/zha/test_binary_sensor.py | 92 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index b277b3fe2671..4e3c7166bf04 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -2,13 +2,16 @@ from __future__ import annotations import functools +from typing import Any + +from zigpy.zcl.clusters.security import IasZone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import STATE_ON, EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -164,6 +167,36 @@ class IASZone(BinarySensor): """Parse the raw attribute into a bool state.""" return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state + # temporary code to migrate old IasZone sensors to update attribute cache state once + # remove in 2024.4.0 + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + return {"migrated_to_cache": True} # writing new state means we're migrated + + # temporary migration code + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + # trigger migration if extra state attribute is not present + if "migrated_to_cache" not in last_state.attributes: + self.migrate_to_zigpy_cache(last_state) + + # temporary migration code + @callback + def migrate_to_zigpy_cache(self, last_state): + """Save old IasZone sensor state to attribute cache.""" + # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here + # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute + if last_state.state == STATE_ON: + migrated_state = IasZone.ZoneStatus.Alarm_1 + else: + migrated_state = IasZone.ZoneStatus(0) + + self._channel.cluster.update_attribute( + IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + ) + @MULTI_MATCH( channel_names="tuya_manufacturer", diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index d633e9173e72..ec25295ed5a5 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -8,12 +8,15 @@ import zigpy.zcl.clusters.security as security from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import restore_state +from homeassistant.util import dt as dt_util from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, send_attributes_report, + update_attribute_cache, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -120,3 +123,92 @@ async def test_binary_sensor( # test rejoin await async_test_rejoin(hass, zigpy_device, [cluster], reporting) assert hass.states.get(entity_id).state == STATE_OFF + + +@pytest.fixture +def core_rs(hass_storage): + """Core.restore_state fixture.""" + + def _storage(entity_id, attributes, state): + now = dt_util.utcnow().isoformat() + + hass_storage[restore_state.STORAGE_KEY] = { + "version": restore_state.STORAGE_VERSION, + "key": restore_state.STORAGE_KEY, + "data": [ + { + "state": { + "entity_id": entity_id, + "state": str(state), + "attributes": attributes, + "last_changed": now, + "last_updated": now, + "context": { + "id": "3c2243ff5f30447eb12e7348cfd5b8ff", + "user_id": None, + }, + }, + "last_seen": now, + } + ], + } + return + + return _storage + + +@pytest.mark.parametrize( + "restored_state", + [ + STATE_ON, + STATE_OFF, + ], +) +async def test_binary_sensor_migration_not_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, + restored_state, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration to zigpy cache.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == restored_state + + # confirm migration extra state attribute was set to True + assert hass.states.get(entity_id).attributes["migrated_to_cache"] + + +async def test_binary_sensor_migration_already_migrated( + hass: HomeAssistant, + zigpy_device_mock, + core_rs, + zha_device_restored, +) -> None: + """Test temporary ZHA IasZone binary_sensor migration doesn't migrate multiple times.""" + + entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) + + zigpy_device = zigpy_device_mock(DEVICE_IAS) + + cluster = zigpy_device.endpoints.get(1).ias_zone + cluster.PLUGGED_ATTR_READS = { + "zone_status": security.IasZone.ZoneStatus.Alarm_1, + } + update_attribute_cache(cluster) + + zha_device = await zha_device_restored(zigpy_device) + entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) + + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_ON # matches attribute cache + assert hass.states.get(entity_id).attributes["migrated_to_cache"] From 8a99d2a566cd281155506d2c0222a2925fb1eee3 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Mar 2023 19:48:21 +0200 Subject: [PATCH 0963/1058] Update frontend to 20230330.0 (#90524) --- 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 8c3fb8c1434b..6a2a904833b6 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==20230329.0"] + "requirements": ["home-assistant-frontend==20230330.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0ed98c78e1df..342942f0dd29 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index cae40bd2c6d3..3cbd6bd3656f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75b7dfeb3be4..b2b78b454149 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230329.0 +home-assistant-frontend==20230330.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From 9478518937eb1fb6b3cca8a8646eeb9aac2945e2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 02:54:31 +0200 Subject: [PATCH 0964/1058] Add entity name translations to LaMetric (#90538) * Add entity name translations to LaMetric * Consistency --- homeassistant/components/lametric/button.py | 8 +++--- homeassistant/components/lametric/select.py | 3 +-- homeassistant/components/lametric/sensor.py | 1 + .../components/lametric/strings.json | 25 +++++++++++++++++++ homeassistant/components/lametric/switch.py | 2 +- 5 files changed, 32 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 74edd9e0afb9..18a0c2f8f728 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -36,28 +36,28 @@ class LaMetricButtonEntityDescription( BUTTONS = [ LaMetricButtonEntityDescription( key="app_next", - name="Next app", + translation_key="app_next", icon="mdi:arrow-right-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_next(), ), LaMetricButtonEntityDescription( key="app_previous", - name="Previous app", + translation_key="app_previous", icon="mdi:arrow-left-bold", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.app_previous(), ), LaMetricButtonEntityDescription( key="dismiss_current", - name="Dismiss current notification", + translation_key="dismiss_current", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_current_notification(), ), LaMetricButtonEntityDescription( key="dismiss_all", - name="Dismiss all notifications", + translation_key="dismiss_all", icon="mdi:bell-cancel", entity_category=EntityCategory.CONFIG, press_fn=lambda api: api.dismiss_all_notifications(), diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index 295003c853e5..b7c0e55745eb 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -37,11 +37,10 @@ class LaMetricSelectEntityDescription( SELECTS = [ LaMetricSelectEntityDescription( key="brightness_mode", - name="Brightness mode", + translation_key="brightness_mode", icon="mdi:brightness-auto", entity_category=EntityCategory.CONFIG, options=["auto", "manual"], - translation_key="brightness_mode", current_fn=lambda device: device.display.brightness_mode.value, select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)), ), diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index c12d368efdfe..0c26d2c7dd58 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -38,6 +38,7 @@ class LaMetricSensorEntityDescription( SENSORS = [ LaMetricSensorEntityDescription( key="rssi", + translation_key="rssi", name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index eb90b21ff20f..21cebe46f26e 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -45,13 +45,38 @@ } }, "entity": { + "button": { + "app_next": { + "name": "Next app" + }, + "app_previous": { + "name": "Previous app" + }, + "dismiss_current": { + "name": "Dismiss current notification" + }, + "dismiss_all": { + "name": "Dismiss all notifications" + } + }, + "sensor": { + "rssi": { + "name": "Wi-Fi signal" + } + }, "select": { "brightness_mode": { + "name": "Brightness mode", "state": { "auto": "Automatic", "manual": "Manual" } } + }, + "switch": { + "bluetooth": { + "name": "Bluetooth" + } } } } diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index f6807648b7b9..c33ec16d617f 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -39,7 +39,7 @@ class LaMetricSwitchEntityDescription( SWITCHES = [ LaMetricSwitchEntityDescription( key="bluetooth", - name="Bluetooth", + translation_key="bluetooth", icon="mdi:bluetooth", entity_category=EntityCategory.CONFIG, available_fn=lambda device: device.bluetooth.available, From e32d89215d39287f61a41e7c66d0681b1fe10c05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:54:13 -1000 Subject: [PATCH 0965/1058] Fix migration when encountering a NULL entity_id/event_type (#90542) * Fix migration when encountering a NULL entity_id/event_type reported in #beta on discord * simplify --- .../components/recorder/migration.py | 30 ++-- tests/components/recorder/test_migrate.py | 150 +++++++++++++++++- 2 files changed, 168 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4be01327654c..fe1d7fdf91c8 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1439,12 +1439,15 @@ def migrate_event_type_ids(instance: Recorder) -> bool: with session_scope(session=session_maker()) as session: if events := session.execute(find_event_type_to_migrate()).all(): event_types = {event_type for _, event_type in events} + if None in event_types: + # event_type should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + event_types.remove(None) + event_types.add(_EMPTY_EVENT_TYPE) + event_type_to_id = event_type_manager.get_many(event_types, session) if missing_event_types := { - # We should never see see None for the event_Type in the events table - # but we need to be defensive so we don't fail the migration - # because of a bad event - _EMPTY_EVENT_TYPE if event_type is None else event_type + event_type for event_type, event_id in event_type_to_id.items() if event_id is None }: @@ -1470,7 +1473,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: { "event_id": event_id, "event_type": None, - "event_type_id": event_type_to_id[event_type], + "event_type_id": event_type_to_id[ + _EMPTY_EVENT_TYPE if event_type is None else event_type + ], } for event_id, event_type in events ], @@ -1502,14 +1507,17 @@ def migrate_entity_ids(instance: Recorder) -> bool: with session_scope(session=instance.get_session()) as session: if states := session.execute(find_entity_ids_to_migrate()).all(): entity_ids = {entity_id for _, entity_id in states} + if None in entity_ids: + # entity_id should never be None but we need to be defensive + # so we don't fail the migration because of a bad state + entity_ids.remove(None) + entity_ids.add(_EMPTY_ENTITY_ID) + entity_id_to_metadata_id = states_meta_manager.get_many( entity_ids, session, True ) if missing_entity_ids := { - # We should never see _EMPTY_ENTITY_ID in the states table - # but we need to be defensive so we don't fail the migration - # because of a bad state - _EMPTY_ENTITY_ID if entity_id is None else entity_id + entity_id for entity_id, metadata_id in entity_id_to_metadata_id.items() if metadata_id is None }: @@ -1537,7 +1545,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: # the history queries still need to work while the # migration is in progress and we will do this in # post_migrate_entity_ids - "metadata_id": entity_id_to_metadata_id[entity_id], + "metadata_id": entity_id_to_metadata_id[ + _EMPTY_ENTITY_ID if entity_id is None else entity_id + ], } for state_id, entity_id in states ], diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index fe4f1e016f5c..6e54513830dd 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -957,7 +957,7 @@ async def test_migrate_entity_ids( instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) - def _insert_events(): + def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( @@ -979,7 +979,7 @@ async def test_migrate_entity_ids( ) ) - await instance.async_add_executor_job(_insert_events) + await instance.async_add_executor_job(_insert_states) await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder @@ -1065,3 +1065,149 @@ async def test_post_migrate_entity_ids( assert states_by_state["one_1"] is None assert states_by_state["two_2"] is None assert states_by_state["two_1"] is None + + +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +async def test_migrate_null_entity_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate entity_ids to the StatesMeta table.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_states(): + with session_scope(hass=hass) as session: + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=1.452529, + ), + ) + session.add_all( + States( + entity_id=None, + state="empty", + last_updated_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + States( + entity_id="sensor.one", + state="one_1", + last_updated_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_states) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + instance.queue_task(EntityIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_states(): + with session_scope(hass=hass) as session: + states = ( + session.query( + States.state, + States.metadata_id, + States.last_updated_ts, + StatesMeta.entity_id, + ) + .outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id) + .all() + ) + assert len(states) == 1002 + result = {} + for state in states: + result.setdefault(state.entity_id, []).append( + { + "state_id": state.entity_id, + "last_updated_ts": state.last_updated_ts, + "state": state.state, + } + ) + return result + + states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) + assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000 + assert len(states_by_entity_id["sensor.one"]) == 2 + + +@pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +async def test_migrate_null_event_type_ids( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + + def _insert_events(): + with session_scope(hass=hass) as session: + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=1.452529, + ), + ) + session.add_all( + Events( + event_type=None, + origin_idx=0, + time_fired_ts=time + 1.452529, + ) + for time in range(1000) + ) + session.add( + Events( + event_type="event_type_one", + origin_idx=0, + time_fired_ts=2.452529, + ), + ) + + await instance.async_add_executor_job(_insert_events) + + await async_wait_recording_done(hass) + # This is a threadsafe way to add a task to the recorder + + instance.queue_task(EventTypeIDMigrationTask()) + await async_recorder_block_till_done(hass) + await async_recorder_block_till_done(hass) + + def _fetch_migrated_events(): + with session_scope(hass=hass) as session: + events = ( + session.query(Events.event_id, Events.time_fired, EventTypes.event_type) + .filter( + Events.event_type_id.in_( + select_event_type_ids( + ( + "event_type_one", + migration._EMPTY_EVENT_TYPE, + ) + ) + ) + ) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .all() + ) + assert len(events) == 1002 + result = {} + for event in events: + result.setdefault(event.event_type, []).append( + { + "event_id": event.event_id, + "time_fired": event.time_fired, + "event_type": event.event_type, + } + ) + return result + + events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) + assert len(events_by_type["event_type_one"]) == 2 + assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 From aad1f4b7662811311ec70f6046325b025f0ee9ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Mar 2023 14:53:47 -1000 Subject: [PATCH 0966/1058] Handle garbage in the context_id column during migration (#90544) * Handle garbage in the context_id column during migration * Update homeassistant/components/recorder/migration.py * lint --- .../components/recorder/migration.py | 14 ++++-- tests/components/recorder/test_migrate.py | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index fe1d7fdf91c8..23382a9aeb39 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1355,10 +1355,16 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None: """Convert a context_id to bytes.""" if context_id is None: return None - if len(context_id) == 32: - return UUID(context_id).bytes - if len(context_id) == 26: - return ulid_to_bytes(context_id) + with contextlib.suppress(ValueError): + # There may be garbage in the context_id column + # from custom integrations that are not UUIDs or + # ULIDs that filled the column to the max length + # so we need to catch the ValueError and return + # None if it happens + if len(context_id) == 32: + return UUID(context_id).bytes + if len(context_id) == 26: + return ulid_to_bytes(context_id) return None diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 6e54513830dd..b75d536d1526 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -671,6 +671,19 @@ async def test_migrate_events_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + Events( + event_type="garbage_context_id_event", + event_data=None, + origin_idx=0, + time_fired=None, + time_fired_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -695,12 +708,13 @@ async def test_migrate_events_context_ids( "empty_context_id_event", "ulid_context_id_event", "invalid_context_id_event", + "garbage_context_id_event", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {event.event_type: _object_as_dict(event) for event in events} events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) @@ -746,6 +760,14 @@ async def test_migrate_events_context_ids( assert invalid_context_id_event["context_user_id_bin"] is None assert invalid_context_id_event["context_parent_id_bin"] is None + garbage_context_id_event = events_by_type["garbage_context_id_event"] + assert garbage_context_id_event["context_id"] is None + assert garbage_context_id_event["context_user_id"] is None + assert garbage_context_id_event["context_parent_id"] is None + assert garbage_context_id_event["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id_event["context_user_id_bin"] is None + assert garbage_context_id_event["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_context_ids", [True]) async def test_migrate_states_context_ids( @@ -803,6 +825,16 @@ async def test_migrate_states_context_ids( context_parent_id=None, context_parent_id_bin=None, ), + States( + entity_id="state.garbage_context_id", + last_updated_ts=1677721632.552529, + context_id="adapt_lgt:b'5Cf*':interval:b'0R'", + context_id_bin=None, + context_user_id=None, + context_user_id_bin=None, + context_parent_id=None, + context_parent_id_bin=None, + ), ) ) @@ -827,12 +859,13 @@ async def test_migrate_states_context_ids( "state.empty_context_id", "state.ulid_context_id", "state.invalid_context_id", + "state.garbage_context_id", ] ) ) .all() ) - assert len(events) == 4 + assert len(events) == 5 return {state.entity_id: _object_as_dict(state) for state in events} states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states) @@ -877,6 +910,14 @@ async def test_migrate_states_context_ids( assert invalid_context_id["context_user_id_bin"] is None assert invalid_context_id["context_parent_id_bin"] is None + garbage_context_id = states_by_entity_id["state.garbage_context_id"] + assert garbage_context_id["context_id"] is None + assert garbage_context_id["context_user_id"] is None + assert garbage_context_id["context_parent_id"] is None + assert garbage_context_id["context_id_bin"] == b"\x00" * 16 + assert garbage_context_id["context_user_id_bin"] is None + assert garbage_context_id["context_parent_id_bin"] is None + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) async def test_migrate_event_type_ids( From 4bf10c01f0df524c8048985c354f2159102eadb3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 30 Mar 2023 20:55:01 -0400 Subject: [PATCH 0967/1058] Bump ZHA dependencies (#90547) * Bump ZHA dependencies * Ensure the network is formed on channel 15 when multi-PAN is in use --- homeassistant/components/zha/core/const.py | 2 ++ homeassistant/components/zha/core/gateway.py | 16 +++++++++++ homeassistant/components/zha/manifest.json | 10 +++---- requirements_all.txt | 10 +++---- requirements_test_all.txt | 10 +++---- tests/components/zha/test_gateway.py | 29 ++++++++++++++++++++ 6 files changed, 62 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 4c10a2328a27..6423723d326d 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -137,6 +137,8 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" +CONF_NWK = "network" +CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3f9ada1ed084..8858ea69590c 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -41,6 +41,8 @@ from .const import ( ATTR_TYPE, CONF_DATABASE, CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -172,6 +174,20 @@ class ZHAGateway: ): 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 ( + is_multiprotocol_url, + ) + + # Until we have a way to coordinate channels with the Thread half of multi-PAN, + # stick to the old zigpy default of channel 15 instead of dynamically scanning + if ( + is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) + and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None + ): + app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 + return app_controller_cls, app_controller_cls.SCHEMA(app_config) async def async_initialize(self) -> None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d82fe5ed0f86..bc5bf6a6d4b7 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -20,15 +20,15 @@ "zigpy_znp" ], "requirements": [ - "bellows==0.34.10", + "bellows==0.35.0", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.95", - "zigpy-deconz==0.19.2", - "zigpy==0.53.2", - "zigpy-xbee==0.16.2", + "zigpy-deconz==0.20.0", + "zigpy==0.54.0", + "zigpy-xbee==0.17.0", "zigpy-zigate==0.10.3", - "zigpy-znp==0.9.3" + "zigpy-znp==0.10.0" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 3cbd6bd3656f..8706e4e5f917 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -2710,19 +2710,19 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2b78b454149..4b1ce6ec3379 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,7 +355,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.34.10 +bellows==0.35.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.13.0 @@ -1938,19 +1938,19 @@ zeversolar==0.3.1 zha-quirks==0.0.95 # homeassistant.components.zha -zigpy-deconz==0.19.2 +zigpy-deconz==0.20.0 # homeassistant.components.zha -zigpy-xbee==0.16.2 +zigpy-xbee==0.17.0 # homeassistant.components.zha zigpy-zigate==0.10.3 # homeassistant.components.zha -zigpy-znp==0.9.3 +zigpy-znp==0.10.0 # homeassistant.components.zha -zigpy==0.53.2 +zigpy==0.54.0 # homeassistant.components.zwave_js zwave-js-server-python==0.47.1 diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 392c589ea18e..be53b22be6aa 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -323,3 +323,32 @@ async def test_gateway_initialize_bellows_thread( await zha_gateway.async_initialize() assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state + + +@pytest.mark.parametrize( + ("device_path", "config_override", "expected_channel"), + [ + ("/dev/ttyUSB0", {}, None), + ("socket://192.168.1.123:9999", {}, None), + ("socket://192.168.1.123:9999", {"network": {"channel": 20}}, 20), + ("socket://core-silabs-multiprotocol:9999", {}, 15), + ("socket://core-silabs-multiprotocol:9999", {"network": {"channel": 20}}, 20), + ], +) +async def test_gateway_force_multi_pan_channel( + device_path: str, + config_override: dict, + expected_channel: int | None, + hass: HomeAssistant, + coordinator, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + zha_gateway = get_zha_gateway(hass) + assert zha_gateway is not None + + zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data) + zha_gateway.config_entry.data["device"]["path"] = device_path + zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) + + _, config = zha_gateway.get_application_controller_data() + assert config["network"]["channel"] == expected_channel From e7e2532c6897854c8a4a6ca0fa9e709d5c50e01f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Mar 2023 20:55:55 -0400 Subject: [PATCH 0968/1058] Bumped version to 2023.4.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 fba1d6545948..b47e1d9fb50e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -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, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 73d680092f80..76c1f186164c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b1" +version = "2023.4.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ab66664f20f9a9d547b3b3b7e35608e245f7b54c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:34:20 +0200 Subject: [PATCH 0969/1058] Allow removal of sensor settings in scrape (#90412) * Allow removal of sensor settings in scrape * Adjust * Adjust * Add comment * Simplify * Simplify * Adjust * Don't allow empty string * Only allow None * Use default as None * Use sentinel "none" * Not needed * Adjust unit of measurement * Add translation keys for "none" * Use translations * Sort * Add enum and timestamp * Use translation references * Remove default and set suggested_values * Disallow enum device class * Adjust tests * Adjust _strip_sentinel --- .../components/scrape/config_flow.py | 39 +++- homeassistant/components/scrape/strings.json | 67 ++++++ tests/components/scrape/conftest.py | 13 +- tests/components/scrape/test_config_flow.py | 193 +++++++++++++++++- 4 files changed, 294 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 1e3635a010c3..3ca13e56b299 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -95,6 +95,8 @@ RESOURCE_SETUP = { vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } +NONE_SENTINEL = "none" + SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -102,28 +104,45 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorDeviceClass], + options=[NONE_SENTINEL] + + sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ] + ), mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", ) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], + options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", ) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], + options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", ) ), } +def _strip_sentinel(options: dict[str, Any]) -> None: + """Convert sentinel to None.""" + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if options[key] == NONE_SENTINEL: + options.pop(key) + + async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -150,6 +169,7 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) + _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx]) + suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) + for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): + if not suggested_values.get(key): + suggested_values[key] = NONE_SENTINEL + return suggested_values async def validate_sensor_edit( @@ -194,6 +218,7 @@ async def validate_sensor_edit( # In this case, we want to add a sub-item so we update the options directly. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) + _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 052ef22848f8..857d53eb5276 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -125,5 +125,72 @@ } } } + }, + "selector": { + "device_class": { + "options": { + "none": "No device class", + "date": "[%key:component::sensor::entity_component::date::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%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_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%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + }, + "unit_of_measurement": { + "options": { + "none": "No unit of measurement" + } + } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index 5ad4f39844e4..026daeea38c6 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -1,8 +1,9 @@ """Fixtures for the Scrape integration.""" from __future__ import annotations +from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid import pytest @@ -32,6 +33,16 @@ from . import MockRestData from tests.common import MockConfigEntry +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Automatically path uuid generator.""" + with patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture(name="get_config") async def get_config_to_integration_load() -> dict[str, Any]: """Return default minimal configuration. diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e508937fed84..9c6c5e0b4de3 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Scrape config flow.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid from homeassistant import config_entries from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN +from homeassistant.components.scrape.config_flow import NONE_SENTINEL from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -15,14 +16,18 @@ from homeassistant.components.scrape.const import ( DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_METHOD, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -34,7 +39,9 @@ from . import MockRestData from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_form( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -46,10 +53,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ) as mock_data, patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_data: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -66,6 +70,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -92,7 +99,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: +async def test_flow_fails( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: """Test config flow error.""" result = await hass.config_entries.flow.async_init( @@ -137,9 +146,6 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: with patch( "homeassistant.components.rest.RestData", return_value=get_data, - ), patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -157,6 +163,9 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -278,6 +287,9 @@ async def test_options_add_remove_sensor_flow( CONF_NAME: "Template", CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -405,6 +417,9 @@ async def test_options_edit_sensor_flow( user_input={ CONF_SELECT: "template", CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -434,3 +449,161 @@ async def test_options_edit_sensor_flow( # Check the state of the entity has changed as expected state = hass.states.get("sensor.current_version") assert state.state == "Trying to get" + + +async def test_sensor_options_add_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } + + +async def test_sensor_options_remove_device_class( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test options flow to edit a sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + options={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: DEFAULT_METHOD, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + CONF_TIMEOUT: DEFAULT_TIMEOUT, + CONF_ENCODING: DEFAULT_ENCODING, + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: "temperature", + CONF_STATE_CLASS: "measurement", + CONF_UNIT_OF_MEASUREMENT: "°C", + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "select_edit_sensor"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"index": "0"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "edit_sensor" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SELECT: ".current-temp h3", + CONF_INDEX: 0.0, + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current Temp", + CONF_SELECT: ".current-temp h3", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + }, + ], + } From de9e7e47feb0baf23ca20b579c0a729a7170366f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 08:33:44 -1000 Subject: [PATCH 0970/1058] Make sonos activity check a background task (#90553) Ensures the task is canceled at shutdown if the device is offline and the ping is still in progress --- homeassistant/components/sonos/speaker.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f97d134c9c25..638ede722f5c 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -591,13 +591,20 @@ class SonosSpeaker: self.async_write_entity_states() self.hass.async_create_task(self.async_subscribe()) - async def async_check_activity(self, now: datetime.datetime) -> None: + @callback + def async_check_activity(self, now: datetime.datetime) -> None: """Validate availability of the speaker based on recent activity.""" if not self.available: return if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT: return + # Ensure the ping is canceled at shutdown + self.hass.async_create_background_task( + self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping" + ) + async def _async_check_activity(self) -> None: + """Validate availability of the speaker based on recent activity.""" try: await self.hass.async_add_executor_job(self.ping) except SonosUpdateError: From 89dc6db5a76fbe27c9acfcf4187e0305e421f752 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 14:55:48 +0200 Subject: [PATCH 0971/1058] Add arming/disarming state to Verisure (#90577) --- homeassistant/components/verisure/alarm_control_panel.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 0cfd6ebb81cf..9615404a9a6c 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,18 +84,24 @@ class VerisureAlarm( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + self._attr_state = STATE_ALARM_DISARMING + self.async_write_ha_state() await self._async_set_arm_state( "DISARMED", self.coordinator.verisure.disarm(code) ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_HOME", self.coordinator.verisure.arm_home(code) ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" + self._attr_state = STATE_ALARM_ARMING + self.async_write_ha_state() await self._async_set_arm_state( "ARMED_AWAY", self.coordinator.verisure.arm_away(code) ) From 88a407361cd798a213b52f8f309ed43f096ab7b9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:16 +0200 Subject: [PATCH 0972/1058] Raise on invalid (dis)arm code in manual alarm (#90579) --- .../components/manual/alarm_control_panel.py | 51 +++++++------------ .../manual/test_alarm_control_panel.py | 23 ++++++--- 2 files changed, 32 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index f0436ba1d698..da77aea6c4af 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_time @@ -285,56 +286,34 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -383,18 +362,22 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( parse_result=False, from_state=self._state, to_state=state ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index 21cbc95d4e68..f1a4b2da2ef6 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -26,6 +26,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -224,12 +225,16 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) - assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE + "2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + { + ATTR_ENTITY_ID: "alarm_control_panel.test", + ATTR_CODE: f"{CODE}2", + }, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -1082,7 +1087,8 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1125,7 +1131,8 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From 499962f4eeccd86867de75d9e5640b5e3aa1daf6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 31 Mar 2023 15:50:49 +0200 Subject: [PATCH 0973/1058] Tweak yalexs_ble translations (#90582) --- homeassistant/components/yalexs_ble/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index 0f1f138fd6cf..c2d1a2155c3a 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -22,7 +22,7 @@ } }, "error": { - "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.", + "no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.", "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%]", From 2d482f1f5741da39cb7e71c0f22f2dee22b1b48e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Mar 2023 16:08:02 +0200 Subject: [PATCH 0974/1058] Raise on invalid (dis)arm code in manual mqtt alarm (#90584) --- .../manual_mqtt/alarm_control_panel.py | 51 +++++++------------ .../manual_mqtt/test_alarm_control_panel.py | 20 +++++--- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index d6b4a58c4130..fd6adb009aae 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -345,56 +346,34 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if not self._async_validate_code(code, STATE_ALARM_DISARMED): - return - + self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() self.async_schedule_update_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_HOME - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_HOME) self._async_update_state(STATE_ALARM_ARMED_HOME) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_AWAY - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_AWAY) self._async_update_state(STATE_ALARM_ARMED_AWAY) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_NIGHT - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT) self._async_update_state(STATE_ALARM_ARMED_NIGHT) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_VACATION - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_VACATION) self._async_update_state(STATE_ALARM_ARMED_VACATION) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self.code_arm_required and not self._async_validate_code( - code, STATE_ALARM_ARMED_CUSTOM_BYPASS - ): - return - + self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS) self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) async def async_alarm_trigger(self, code: str | None = None) -> None: @@ -436,18 +415,22 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def _async_validate_code(self, code, state): """Validate given code.""" - if self._code is None: - return True + if ( + state != STATE_ALARM_DISARMED and not self.code_arm_required + ) or self._code is None: + return + if isinstance(self._code, str): alarm_code = self._code else: alarm_code = self._code.async_render( from_state=self._state, to_state=state, parse_result=False ) - check = not alarm_code or code == alarm_code - if not check: - _LOGGER.warning("Invalid code given for %s", state) - return check + + if not alarm_code or code == alarm_code: + return + + raise HomeAssistantError("Invalid alarm code provided") @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 8aaccad10569..549fa995179e 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -280,12 +281,13 @@ async def test_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED - await hass.services.async_call( - alarm_control_panel.DOMAIN, - service, - {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"): + await hass.services.async_call( + alarm_control_panel.DOMAIN, + service, + {ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"}, + blocking=True, + ) assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED @@ -881,7 +883,8 @@ async def test_disarm_during_trigger_with_invalid_code( assert hass.states.get(entity_id).state == STATE_ALARM_PENDING - await common.async_alarm_disarm(hass, entity_id=entity_id) + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, entity_id=entity_id) assert hass.states.get(entity_id).state == STATE_ALARM_PENDING @@ -1307,7 +1310,8 @@ async def test_disarm_with_template_code( state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME - await common.async_alarm_disarm(hass, "def") + with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"): + await common.async_alarm_disarm(hass, "def") state = hass.states.get(entity_id) assert state.state == STATE_ALARM_ARMED_HOME From a20771f57155e2a74839c3f45ecf0df26d2cfa8e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 31 Mar 2023 20:31:04 +0200 Subject: [PATCH 0975/1058] Bump reolink-aio to 0.5.9 (#90590) --- 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 79fc15c571de..b8de6cd83991 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.5.8"] + "requirements": ["reolink-aio==0.5.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8706e4e5f917..1a6737c99de3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b1ce6ec3379..9d44d8dabdd4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0 renault-api==0.1.12 # homeassistant.components.reolink -reolink-aio==0.5.8 +reolink-aio==0.5.9 # homeassistant.components.python_script restrictedpython==6.0 From c63f8e714ee33c8994393d364de785b044aacf04 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 31 Mar 2023 20:15:49 +0200 Subject: [PATCH 0976/1058] Update frontend to 20230331.0 (#90594) --- 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 6a2a904833b6..114760923eb8 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==20230330.0"] + "requirements": ["home-assistant-frontend==20230331.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 342942f0dd29..cde6be3c204c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 1a6737c99de3..9843965125e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d44d8dabdd4..a249c7c7dbcc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230330.0 +home-assistant-frontend==20230331.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From f56ccf90d91b79113bf29f897d1b02e36ccc43b3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 14:53:42 -0400 Subject: [PATCH 0977/1058] Fix ZHA definition error on received command (#90602) * Fix use of deprecated command schema access * Add a unit test --- .../components/zha/core/channels/base.py | 10 +++++++--- tests/components/zha/test_base.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 tests/components/zha/test_base.py diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index ae5980cd6306..6d4899be37c6 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -58,15 +58,19 @@ class AttrReportConfig(TypedDict, total=True): def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + try: + name = channel.cluster.server_commands[command_id].name + except KeyError: + name = f"0x{command_id:02X}" + channel.debug( "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, + name, args, channel.cluster.cluster_id, tsn, ) - return cmd + return name def decorate_command(channel, command): diff --git a/tests/components/zha/test_base.py b/tests/components/zha/test_base.py new file mode 100644 index 000000000000..fbb25f1cbd34 --- /dev/null +++ b/tests/components/zha/test_base.py @@ -0,0 +1,19 @@ +"""Test ZHA base channel module.""" + +from homeassistant.components.zha.core.channels.base import parse_and_log_command + +from tests.components.zha.test_channels import ( # noqa: F401 + channel_pool, + poll_control_ch, + zigpy_coordinator_device, +) + + +def test_parse_and_log_command(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses a known command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop" + + +def test_parse_and_log_command_unknown(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses an unknown command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB" From 590db0fa74c931728db2ed0074ddae36a8c6dec6 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:37:00 -0400 Subject: [PATCH 0978/1058] Perform an energy scan when downloading ZHA diagnostics (#90605) --- homeassistant/components/zha/diagnostics.py | 9 +++++++++ tests/components/zha/test_diagnostics.py | 20 ++++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 2e0653b47e19..966f35fe98bb 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -7,6 +7,7 @@ from typing import Any from zigpy.config import CONF_NWK_EXTENDED_PAN_ID from zigpy.profiles import PROFILES +from zigpy.types import Channels from zigpy.zcl import Cluster from homeassistant.components.diagnostics.util import async_redact_data @@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + + energy_scan = await gateway.application_controller.energy_scan( + channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 + ) + return async_redact_data( { "config": config, "config_entry": config_entry.as_dict(), "application_state": shallow_asdict(gateway.application_controller.state), + "energy_scan": { + channel: 100 * energy / 255 for channel, energy in energy_scan.items() + }, "versions": { "bellows": version("bellows"), "zigpy": version("zigpy"), diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 61f855af9afc..5ec555d88dfc 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,6 +6,7 @@ import zigpy.profiles.zha as zha import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform @@ -62,14 +63,25 @@ async def test_diagnostics_for_config_entry( ) -> None: """Test diagnostics for config entry.""" await zha_device_joined(zigpy_device) - diagnostics_data = await get_diagnostics_for_config_entry( - hass, hass_client, config_entry - ) - assert diagnostics_data + + gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + scan = {c: c for c in range(11, 26 + 1)} + + with patch.object(gateway.application_controller, "energy_scan", return_value=scan): + diagnostics_data = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + for key in CONFIG_ENTRY_DIAGNOSTICS_KEYS: assert key in diagnostics_data assert diagnostics_data[key] is not None + # Energy scan results are presented as a percentage. JSON object keys also must be + # strings, not integers. + assert diagnostics_data["energy_scan"] == { + str(k): 100 * v / 255 for k, v in scan.items() + } + async def test_diagnostics_for_device( hass: HomeAssistant, From b3348c3e6ffbfc04bdd5e695cd0342793d2ce296 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:39:08 -0400 Subject: [PATCH 0979/1058] Bump zwave-js-server-python to 0.47.3 (#90606) * Bump zwave-js-server-python to 0.47.2 * Bump zwave-js-server-python to 0.47.3 --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 5fb7726577bf..d41ee0272a93 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 9843965125e2..ee45c2b38780 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2728,7 +2728,7 @@ zigpy==0.54.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a249c7c7dbcc..5e9d6cc001a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1953,7 +1953,7 @@ zigpy-znp==0.10.0 zigpy==0.54.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.47.1 +zwave-js-server-python==0.47.3 # homeassistant.components.zwave_me zwave_me_ws==0.3.6 From 03f085d7be71d3a08733dc10949a498168479088 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Mar 2023 15:41:37 -0400 Subject: [PATCH 0980/1058] Bumped version to 2023.4.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 b47e1d9fb50e..38c243997a49 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -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, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 76c1f186164c..f000a293dba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b2" +version = "2023.4.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6242dd2214c42ca403c2932f1b3f748cced7a069 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 11:27:55 -1000 Subject: [PATCH 0981/1058] Avoid sorting domain/all states in templates (#90608) --- homeassistant/helpers/template.py | 6 ++-- tests/helpers/test_event.py | 4 ++- tests/helpers/test_template.py | 47 ++++++++++++++++++------------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 36e0a597b87a..8e5951488ba0 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -13,7 +13,7 @@ from functools import cache, lru_cache, partial, wraps import json import logging import math -from operator import attrgetter, contains +from operator import contains import pathlib import random import re @@ -983,7 +983,7 @@ def _state_generator( hass: HomeAssistant, domain: str | None ) -> Generator[TemplateState, None, None]: """State generator for a domain or all states.""" - for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")): + for state in hass.states.async_all(domain): yield _template_state_no_collect(hass, state) @@ -1097,7 +1097,7 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: _collect_state(hass, entity_id) found[entity_id] = entity - return sorted(found.values(), key=lambda a: a.entity_id) + return list(found.values()) def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 7e84d634effb..a482e1b63b5d 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3043,7 +3043,9 @@ async def test_async_track_template_result_multiple_templates_mixing_domain( template_1 = Template("{{ states.switch.test.state == 'on' }}") template_2 = Template("{{ states.switch.test.state == 'on' }}") template_3 = Template("{{ states.switch.test.state == 'off' }}") - template_4 = Template("{{ states.switch | map(attribute='entity_id') | list }}") + template_4 = Template( + "{{ states.switch | sort(attribute='entity_id') | map(attribute='entity_id') | list }}" + ) refresh_runs = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f185191d1bfd..4b3b9488bd84 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -185,7 +185,7 @@ def test_raise_exception_on_error(hass: HomeAssistant) -> None: def test_iterating_all_states(hass: HomeAssistant) -> None: """Test iterating all states.""" - tmpl_str = "{% for state in states %}{{ state.state }}{% endfor %}" + tmpl_str = "{% for state in states | sort(attribute='entity_id') %}{{ state.state }}{% endfor %}" info = render_to_info(hass, tmpl_str) assert_result_info(info, "", all_states=True) @@ -2511,20 +2511,22 @@ async def test_expand(hass: HomeAssistant) -> None: hass.states.async_set("test.object", "happy") info = render_to_info( - hass, "{{ expand('test.object') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('test.object') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", ["test.object"]) assert info.rate_limit is None info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", ["group.new_group"]) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", [], ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2535,13 +2537,14 @@ async def test_expand(hass: HomeAssistant) -> None: info = render_to_info( hass, - "{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}", + "{{ expand('group.new_group') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"group.new_group", "test.object"}) assert info.rate_limit is None info = render_to_info( - hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"test.object"}, ["group"]) assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT @@ -2550,7 +2553,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ expand('group.new_group', 'test.object')" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2559,7 +2562,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ ['group.new_group', 'test.object'] | expand" - " | map(attribute='entity_id') | join(', ') }}" + " | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "test.object", {"test.object", "group.new_group"}) @@ -2579,7 +2582,7 @@ async def test_expand(hass: HomeAssistant) -> None: hass, ( "{{ states.group.power_sensors.attributes.entity_id | expand " - "| map(attribute='state')|map('float')|sum }}" + "| sort(attribute='entity_id') | map(attribute='state')|map('float')|sum }}" ), ) assert_result_info( @@ -2607,7 +2610,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('light.grouped') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('light.grouped') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2629,7 +2633,8 @@ async def test_expand(hass: HomeAssistant) -> None: }, ) info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2644,7 +2649,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2659,7 +2665,8 @@ async def test_expand(hass: HomeAssistant) -> None: await hass.async_block_till_done() info = render_to_info( - hass, "{{ expand('zone.test') | map(attribute='entity_id') | join(', ') }}" + hass, + "{{ expand('zone.test') | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info( info, @@ -2709,7 +2716,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "", ["light.hue_5678"]) @@ -2721,7 +2728,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info(info, "light.hue_5678", ["light.hue_5678"]) @@ -2743,7 +2750,7 @@ async def test_device_entities( hass, ( f"{{{{ device_entities('{device_entry.id}') | expand " - "| map(attribute='entity_id') | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}" ), ) assert_result_info( @@ -3384,7 +3391,7 @@ def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> Non {% elif states.light.a == "on" %} {{ states[domain] | list }} {% elif states('light.b') == "on" %} - {{ states[otherdomain] | map(attribute='entity_id') | list }} + {{ states[otherdomain] | sort(attribute='entity_id') | map(attribute='entity_id') | list }} {% elif states.light.a == "on" %} {{ states["nonexist"] | list }} {% else %} @@ -4205,7 +4212,7 @@ async def test_lights(hass: HomeAssistant) -> None: """Test we can sort lights.""" tmpl = """ - {% set lights_on = states.light|selectattr('state','eq','on')|map(attribute='name')|list %} + {% set lights_on = states.light|selectattr('state','eq','on')|sort(attribute='entity_id')|map(attribute='name')|list %} {% if lights_on|length == 0 %} No lights on. Sleep well.. {% elif lights_on|length == 1 %} @@ -4308,7 +4315,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: tpl = template.Template( ( "{{ states | selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list | join(', ') }}" + "| sort(attribute='entity_id') | map(attribute='entity_id') | list | join(', ') }}" ), hass, ) @@ -4318,7 +4325,7 @@ async def test_unavailable_states(hass: HomeAssistant) -> None: ( "{{ states.light " "| selectattr('state', 'in', ['unavailable','unknown','none']) " - "| map(attribute='entity_id') | list " + "| sort(attribute='entity_id') | map(attribute='entity_id') | list " "| join(', ') }}" ), hass, From d5d5bb0732b8cf131e4d7934552c6522259da744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 31 Mar 2023 23:57:39 +0200 Subject: [PATCH 0982/1058] Only limit stats to started add-ons (#90611) --- homeassistant/components/hassio/__init__.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index d5449cf927bd..e6ff9888b159 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -870,23 +870,25 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.hassio.get_os_info(), ) - addons = [ - addon - for addon in self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - if addon[ATTR_STATE] == ATTR_STARTED + all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) + started_addons = [ + addon for addon in all_addons if addon[ATTR_STATE] == ATTR_STARTED ] stats_data = await asyncio.gather( - *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in started_addons] ) self.hass.data[DATA_ADDONS_STATS] = dict(stats_data) self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( await asyncio.gather( - *[self._update_addon_changelog(addon[ATTR_SLUG]) for addon in addons] + *[ + self._update_addon_changelog(addon[ATTR_SLUG]) + for addon in all_addons + ] ) ) self.hass.data[DATA_ADDONS_INFO] = dict( await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in addons] + *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] ) ) From 1189b2ad70bddae4714a9df3fd41dcc3e9c50f78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Mar 2023 15:15:36 -1000 Subject: [PATCH 0983/1058] Small speed up to _collection_changed (#90621) attrgetter builds a fast method which happens in native code https://github.com/python/cpython/blob/4664a7cf689946f0c9854cadee7c6aa9c276a8cf/Modules/_operator.c#L1413 --- homeassistant/helpers/collection.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 437cd4187194..9da6f84207a2 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,6 +7,7 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from itertools import groupby import logging +from operator import attrgetter from typing import Any, cast import voluptuous as vol @@ -410,9 +411,8 @@ def sync_entity_lifecycle( # Create a new bucket every time we have a different change type # to ensure operations happen in order. We only group # the same change type. - for _, grouped in groupby( - change_sets, lambda change_set: change_set.change_type - ): + groupby_key = attrgetter("change_type") + for _, grouped in groupby(change_sets, groupby_key): new_entities = [ entity for entity in await asyncio.gather( From 75694307e2ac768d021e613cea372a92d5d57b83 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 1 Apr 2023 09:15:17 -1000 Subject: [PATCH 0984/1058] Bump zeroconf to 0.51.0 (#90622) * Bump zeroconf to 0.50.0 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.47.4...0.50.0 * bump to 51 --- homeassistant/components/zeroconf/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/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index b7a643bb46b7..36c2fcc12791 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.47.4"] + "requirements": ["zeroconf==0.51.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cde6be3c204c..da8e3ca3871e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.47.4 +zeroconf==0.51.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index ee45c2b38780..541b841acc71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e9d6cc001a7..7bf409b71f14 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,7 +1929,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.47.4 +zeroconf==0.51.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From bacd77a03addbe094deea7bd296395c5a5216ef6 Mon Sep 17 00:00:00 2001 From: nono Date: Sat, 1 Apr 2023 17:45:24 +0200 Subject: [PATCH 0985/1058] Fix Rest switch init was not retrying if unreachable at setup (#90627) * Fix Rest switch init was not retrying if unreachable at setup * pass error log to platformnotready prevents spamming the same message in logs. --- homeassistant/components/rest/switch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index cda35d1f918a..9e016db0376a 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,8 +98,8 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("No route to resource/endpoint: %s", resource) + except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc class RestSwitch(TemplateEntity, SwitchEntity): From c006b3b1df81203b9aa822dc6292a2df3d1378ce Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 1 Apr 2023 21:17:53 +0200 Subject: [PATCH 0986/1058] Fix mqtt device_tracker is not reloading yaml (#90639) --- homeassistant/components/mqtt/const.py | 1 + tests/components/mqtt/test_device_tracker.py | 21 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index bb6b8ed497d3..41fd353359e3 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -113,6 +113,7 @@ RELOADABLE_PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DEVICE_TRACKER, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index a8c45f8cd75d..a0ac73953b4b 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -10,10 +10,17 @@ 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 .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message +from .test_common import ( + help_test_reloadable, + help_test_setting_blocked_attribute_via_mqtt_json_message, +) from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator, WebSocketGenerator +from tests.typing import ( + MqttMockHAClientGenerator, + MqttMockPahoClient, + WebSocketGenerator, +) DEFAULT_CONFIG = { mqtt.DOMAIN: { @@ -603,3 +610,13 @@ async def test_setup_with_modern_schema( dev_id = "jan" entity_id = f"{device_tracker.DOMAIN}.{dev_id}" assert hass.states.get(entity_id) is not None + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = device_tracker.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) From 2a28d40dc88627195b9d926d679f77a90699ee67 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 1 Apr 2023 21:21:51 +0200 Subject: [PATCH 0987/1058] Update frontend to 20230401.0 (#90646) --- 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 114760923eb8..6468bd6daa68 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==20230331.0"] + "requirements": ["home-assistant-frontend==20230401.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index da8e3ca3871e..4763b3ab948c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ha-av==10.0.0 hass-nabucasa==0.63.1 hassil==1.0.6 home-assistant-bluetooth==1.9.3 -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 home-assistant-intents==2023.3.29 httpx==0.23.3 ifaddr==0.1.7 diff --git a/requirements_all.txt b/requirements_all.txt index 541b841acc71..bfb35d4658de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -907,7 +907,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bf409b71f14..a0cc8fe7df8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230331.0 +home-assistant-frontend==20230401.0 # homeassistant.components.conversation home-assistant-intents==2023.3.29 From aa6cf3d2083babbef62e23106effa5063e1b7978 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Apr 2023 15:23:53 -0400 Subject: [PATCH 0988/1058] Bumped version to 2023.4.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 38c243997a49..039f5bcc7b1d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,7 +8,7 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 4 -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, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index f000a293dba9..bce981eb6eef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.4.0b3" +version = "2023.4.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 1ff93518b5e6dbd014d21e70f11159b7a4656a53 Mon Sep 17 00:00:00 2001 From: mletenay Date: Mon, 3 Apr 2023 02:25:29 +0200 Subject: [PATCH 0989/1058] Update goodwe library to v0.2.30 (#90607) --- homeassistant/components/goodwe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 8dad8454d6b9..45d02dcd2e3d 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.29"] + "requirements": ["goodwe==0.2.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index bfb35d4658de..8ecf19d47fd5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -798,7 +798,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.29 +goodwe==0.2.30 # homeassistant.components.google_mail google-api-python-client==2.71.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0cc8fe7df8e..5229a53734a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -614,7 +614,7 @@ glances_api==0.4.1 goalzero==0.2.1 # homeassistant.components.goodwe -goodwe==0.2.29 +goodwe==0.2.30 # homeassistant.components.google_mail google-api-python-client==2.71.0 From c259c1afe3a9857be2eacde3b49af3f0a513554a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 03:39:46 +0200 Subject: [PATCH 0990/1058] Add entity name translations to Brother (#90634) * Add entity name translations * Fix sensor name * Update tests * Suggested change --- homeassistant/components/brother/sensor.py | 68 +++++------ homeassistant/components/brother/strings.json | 106 ++++++++++++++++++ tests/components/brother/test_sensor.py | 42 +++---- 3 files changed, 161 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 274576f0f31d..191bfff249c8 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -53,14 +53,14 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="status", icon="mdi:printer", - name="Status", + translation_key="status", entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.status, ), BrotherSensorEntityDescription( key="page_counter", icon="mdi:file-document-outline", - name="Page counter", + translation_key="page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -69,7 +69,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="bw_counter", icon="mdi:file-document-outline", - name="B/W counter", + translation_key="bw_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -78,7 +78,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="color_counter", icon="mdi:file-document-outline", - name="Color counter", + translation_key="color_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -87,7 +87,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="duplex_unit_pages_counter", icon="mdi:file-document-outline", - name="Duplex unit pages counter", + translation_key="duplex_unit_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -96,7 +96,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_life", icon="mdi:chart-donut", - name="Drum remaining life", + translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -105,7 +105,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_remaining_pages", icon="mdi:chart-donut", - name="Drum remaining pages", + translation_key="drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -114,7 +114,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="drum_counter", icon="mdi:chart-donut", - name="Drum counter", + translation_key="drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -123,7 +123,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_life", icon="mdi:chart-donut", - name="Black drum remaining life", + translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_remaining_pages", icon="mdi:chart-donut", - name="Black drum remaining pages", + translation_key="black_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -141,7 +141,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_drum_counter", icon="mdi:chart-donut", - name="Black drum counter", + translation_key="black_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -150,7 +150,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_life", icon="mdi:chart-donut", - name="Cyan drum remaining life", + translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", icon="mdi:chart-donut", - name="Cyan drum remaining pages", + translation_key="cyan_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -168,7 +168,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_drum_counter", icon="mdi:chart-donut", - name="Cyan drum counter", + translation_key="cyan_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -177,7 +177,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_life", icon="mdi:chart-donut", - name="Magenta drum remaining life", + translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -186,7 +186,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", icon="mdi:chart-donut", - name="Magenta drum remaining pages", + translation_key="magenta_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -195,7 +195,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_drum_counter", icon="mdi:chart-donut", - name="Magenta drum counter", + translation_key="magenta_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -204,7 +204,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_life", icon="mdi:chart-donut", - name="Yellow drum remaining life", + translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -213,7 +213,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", icon="mdi:chart-donut", - name="Yellow drum remaining pages", + translation_key="yellow_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -222,7 +222,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_drum_counter", icon="mdi:chart-donut", - name="Yellow drum counter", + translation_key="yellow_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -231,7 +231,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="belt_unit_remaining_life", icon="mdi:current-ac", - name="Belt unit remaining life", + translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -240,7 +240,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="fuser_remaining_life", icon="mdi:water-outline", - name="Fuser remaining life", + translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -249,7 +249,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="laser_remaining_life", icon="mdi:spotlight-beam", - name="Laser remaining life", + translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -258,7 +258,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_1_remaining_life", icon="mdi:printer-3d", - name="PF Kit 1 remaining life", + translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -267,7 +267,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="pf_kit_mp_remaining_life", icon="mdi:printer-3d", - name="PF Kit MP remaining life", + translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -276,7 +276,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Black toner remaining", + translation_key="black_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -285,7 +285,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan toner remaining", + translation_key="cyan_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -294,7 +294,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta toner remaining", + translation_key="magenta_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -303,7 +303,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_toner_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow toner remaining", + translation_key="yellow_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -312,7 +312,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="black_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Black ink remaining", + translation_key="black_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -321,7 +321,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="cyan_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Cyan ink remaining", + translation_key="cyan_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -330,7 +330,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="magenta_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Magenta ink remaining", + translation_key="magenta_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -339,7 +339,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="yellow_ink_remaining", icon="mdi:printer-3d-nozzle", - name="Yellow ink remaining", + translation_key="yellow_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -347,7 +347,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="last_restart", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index 9d7d42abefa0..3ee3fe7609ff 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -25,5 +25,111 @@ "unsupported_model": "This printer model is not supported.", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "status": { + "name": "Status" + }, + "page_counter": { + "name": "Page counter" + }, + "bw_pages": { + "name": "B/W pages" + }, + "color_pages": { + "name": "Color pages" + }, + "duplex_unit_page_counter": { + "name": "Duplex unit page counter" + }, + "drum_remaining_life": { + "name": "Drum remaining life" + }, + "drum_remaining_pages": { + "name": "Drum remaining pages" + }, + "drum_page_counter": { + "name": "Drum page counter" + }, + "black_drum_remaining_life": { + "name": "Black drum remaining life" + }, + "black_drum_remaining_pages": { + "name": "Black drum remaining pages" + }, + "black_drum_page_counter": { + "name": "Black drum page counter" + }, + "cyan_drum_remaining_life": { + "name": "Cyan drum remaining life" + }, + "cyan_drum_remaining_pages": { + "name": "Cyan drum remaining pages" + }, + "cyan_drum_page_counter": { + "name": "Cyan drum page counter" + }, + "magenta_drum_remaining_life": { + "name": "Magenta drum remaining life" + }, + "magenta_drum_remaining_pages": { + "name": "Magenta drum remaining pages" + }, + "magenta_drum_page_counter": { + "name": "Magenta drum page counter" + }, + "yellow_drum_remaining_life": { + "name": "Yellow drum remaining life" + }, + "yellow_drum_remaining_pages": { + "name": "Yellow drum remaining pages" + }, + "yellow_drum_page_counter": { + "name": "Yellow drum page counter" + }, + "belt_unit_remaining_life": { + "name": "Belt unit remaining life" + }, + "fuser_remaining_life": { + "name": "Fuser remaining life" + }, + "laser_remaining_life": { + "name": "Laser remaining life" + }, + "pf_kit_1_remaining_life": { + "name": "PF Kit 1 remaining life" + }, + "pf_kit_mp_remaining_life": { + "name": "PF Kit MP remaining life" + }, + "black_toner_remaining": { + "name": "Black toner remaining" + }, + "cyan_toner_remaining": { + "name": "Cyan toner remaining" + }, + "magenta_toner_remaining": { + "name": "Magenta toner remaining" + }, + "yellow_toner_remaining": { + "name": "Yellow toner remaining" + }, + "black_ink_remaining": { + "name": "Black ink remaining" + }, + "cyan_ink_remaining": { + "name": "Cyan ink remaining" + }, + "magenta_ink_remaining": { + "name": "Magenta ink remaining" + }, + "yellow_ink_remaining": { + "name": "Yellow ink remaining" + }, + "last_restart": { + "name": "Last restart" + } + } } } diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index 6769d2194031..e05fce9df3c3 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -43,7 +43,7 @@ async def test_sensors(hass: HomeAssistant) -> None: SENSOR_DOMAIN, DOMAIN, "0123456789_uptime", - suggested_object_id="hl_l2340dw_uptime", + suggested_object_id="hl_l2340dw_last_restart", disabled_by=None, ) test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC) @@ -132,14 +132,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "986" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_drum_page_counter") assert entry assert entry.unique_id == "0123456789_drum_counter" @@ -165,14 +165,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_black_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_black_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_black_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_black_drum_page_counter") assert entry assert entry.unique_id == "0123456789_black_drum_counter" @@ -198,14 +198,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_cyan_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_page_counter") assert entry assert entry.unique_id == "0123456789_cyan_drum_counter" @@ -231,14 +231,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_magenta_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_page_counter") assert entry assert entry.unique_id == "0123456789_magenta_drum_counter" @@ -264,14 +264,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_pages" - state = hass.states.get("sensor.hl_l2340dw_yellow_drum_counter") + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "1611" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_counter") + entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_page_counter") assert entry assert entry.unique_id == "0123456789_yellow_drum_counter" @@ -319,40 +319,40 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_page_counter" - state = hass.states.get("sensor.hl_l2340dw_duplex_unit_pages_counter") + state = hass.states.get("sensor.hl_l2340dw_duplex_unit_page_counter") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "538" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_pages_counter") + entry = registry.async_get("sensor.hl_l2340dw_duplex_unit_page_counter") assert entry assert entry.unique_id == "0123456789_duplex_unit_pages_counter" - state = hass.states.get("sensor.hl_l2340dw_b_w_counter") + state = hass.states.get("sensor.hl_l2340dw_b_w_pages") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "709" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_b_w_counter") + entry = registry.async_get("sensor.hl_l2340dw_b_w_pages") assert entry assert entry.unique_id == "0123456789_bw_counter" - state = hass.states.get("sensor.hl_l2340dw_color_counter") + state = hass.states.get("sensor.hl_l2340dw_color_pages") assert state assert state.attributes.get(ATTR_ICON) == "mdi:file-document-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PAGES assert state.state == "902" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_color_counter") + entry = registry.async_get("sensor.hl_l2340dw_color_pages") assert entry assert entry.unique_id == "0123456789_color_counter" - state = hass.states.get("sensor.hl_l2340dw_uptime") + state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None @@ -360,7 +360,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state.state == "2019-09-24T12:14:56+00:00" assert state.attributes.get(ATTR_STATE_CLASS) is None - entry = registry.async_get("sensor.hl_l2340dw_uptime") + entry = registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" @@ -370,10 +370,10 @@ async def test_disabled_by_default_sensors(hass: HomeAssistant) -> None: await init_integration(hass) registry = er.async_get(hass) - state = hass.states.get("sensor.hl_l2340dw_uptime") + state = hass.states.get("sensor.hl_l2340dw_last_restart") assert state is None - entry = registry.async_get("sensor.hl_l2340dw_uptime") + entry = registry.async_get("sensor.hl_l2340dw_last_restart") assert entry assert entry.unique_id == "0123456789_uptime" assert entry.disabled From cbe3cabf0a94f1e0ba9c8f14fb1924d1f71013b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 14:54:21 -1000 Subject: [PATCH 0991/1058] Add object source logger to profiler (#90650) * Add object source logger to profiler * fixes * cleanup * tweaks * logging * logging * too intensive * adjust * Update homeassistant/bootstrap.py * fixes * fixes * coverage --- homeassistant/components/profiler/__init__.py | 196 ++++++++++++++++-- .../components/profiler/services.yaml | 29 ++- tests/components/profiler/test_init.py | 119 ++++++++++- 3 files changed, 314 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 27e302f47c4f..95ce69aed4a9 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SCAN_INTERVAL, CONF_TYPE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval @@ -29,6 +29,8 @@ SERVICE_START = "start" SERVICE_MEMORY = "memory" SERVICE_START_LOG_OBJECTS = "start_log_objects" SERVICE_STOP_LOG_OBJECTS = "stop_log_objects" +SERVICE_START_LOG_OBJECT_SOURCES = "start_log_object_sources" +SERVICE_STOP_LOG_OBJECT_SOURCES = "stop_log_object_sources" SERVICE_DUMP_LOG_OBJECTS = "dump_log_objects" SERVICE_LRU_STATS = "lru_stats" SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" @@ -60,7 +62,10 @@ SERVICES = ( DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) +DEFAULT_MAX_OBJECTS = 5 + CONF_SECONDS = "seconds" +CONF_MAX_OBJECTS = "max_objects" LOG_INTERVAL_SUB = "log_interval_subscription" @@ -85,7 +90,7 @@ async def async_setup_entry( # noqa: C901 async def _async_start_log_objects(call: ServiceCall) -> None: if LOG_INTERVAL_SUB in domain_data: - domain_data[LOG_INTERVAL_SUB]() + raise HomeAssistantError("Object logging already started") persistent_notification.async_create( hass, @@ -103,21 +108,53 @@ async def async_setup_entry( # noqa: C901 async def _async_stop_log_objects(call: ServiceCall) -> None: if LOG_INTERVAL_SUB not in domain_data: - return + raise HomeAssistantError("Object logging not running") persistent_notification.async_dismiss(hass, "profile_object_logging") domain_data.pop(LOG_INTERVAL_SUB)() - def _safe_repr(obj: Any) -> str: - """Get the repr of an object but keep going if there is an exception. + async def _async_start_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB in domain_data: + raise HomeAssistantError("Object logging already started") - We wrap repr to ensure if one object cannot be serialized, we can - still get the rest. - """ - try: - return repr(obj) - except Exception: # pylint: disable=broad-except - return f"Failed to serialize {type(obj)}" + persistent_notification.async_create( + hass, + ( + "Object source logging has started. See [the logs](/config/logs) to" + " track the growth of new objects." + ), + title="Object source logging started", + notification_id="profile_object_source_logging", + ) + + last_ids: set[int] = set() + last_stats: dict[str, int] = {} + + async def _log_object_sources_with_max(*_: Any) -> None: + await hass.async_add_executor_job( + _log_object_sources, call.data[CONF_MAX_OBJECTS], last_ids, last_stats + ) + + await _log_object_sources_with_max() + cancel_track = async_track_time_interval( + hass, _log_object_sources_with_max, call.data[CONF_SCAN_INTERVAL] + ) + + @callback + def _cancel(): + cancel_track() + last_ids.clear() + last_stats.clear() + + domain_data[LOG_INTERVAL_SUB] = _cancel + + @callback + def _async_stop_object_sources(call: ServiceCall) -> None: + if LOG_INTERVAL_SUB not in domain_data: + raise HomeAssistantError("Object logging not running") + + persistent_notification.async_dismiss(hass, "profile_object_source_logging") + domain_data.pop(LOG_INTERVAL_SUB)() def _dump_log_objects(call: ServiceCall) -> None: # Imports deferred to avoid loading modules @@ -143,15 +180,6 @@ async def async_setup_entry( # noqa: C901 notification_id="profile_object_dump", ) - def _get_function_absfile(func: Any) -> str: - """Get the absolute file path of a function.""" - import inspect # pylint: disable=import-outside-toplevel - - abs_file = "unknown" - with suppress(Exception): - abs_file = inspect.getabsfile(func) - return abs_file - def _lru_stats(call: ServiceCall) -> None: """Log the stats of all lru caches.""" # Imports deferred to avoid loading modules @@ -164,7 +192,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER.critical( "Cache stats for lru_cache %s at %s: %s", lru.__wrapped__, - _get_function_absfile(lru.__wrapped__), + _get_function_absfile(lru.__wrapped__) or "unknown", lru.cache_info(), ) @@ -175,7 +203,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER.critical( "Cache stats for LRU %s at %s: %s", type(class_with_lru_attr), - _get_function_absfile(class_with_lru_attr), + _get_function_absfile(class_with_lru_attr) or "unknown", maybe_lru.get_stats(), ) @@ -267,6 +295,30 @@ async def async_setup_entry( # noqa: C901 _async_stop_log_objects, ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_START_LOG_OBJECT_SOURCES, + _async_start_object_sources, + schema=vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_MAX_OBJECTS, default=DEFAULT_MAX_OBJECTS): vol.Range( + min=1, max=1024 + ), + } + ), + ) + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_STOP_LOG_OBJECT_SOURCES, + _async_stop_object_sources, + ) + async_register_admin_service( hass, DOMAIN, @@ -404,3 +456,101 @@ def _log_objects(*_): import objgraph # pylint: disable=import-outside-toplevel _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) + + +def _get_function_absfile(func: Any) -> str | None: + """Get the absolute file path of a function.""" + import inspect # pylint: disable=import-outside-toplevel + + abs_file: str | None = None + with suppress(Exception): + abs_file = inspect.getabsfile(func) + return abs_file + + +def _safe_repr(obj: Any) -> str: + """Get the repr of an object but keep going if there is an exception. + + We wrap repr to ensure if one object cannot be serialized, we can + still get the rest. + """ + try: + return repr(obj) + except Exception: # pylint: disable=broad-except + return f"Failed to serialize {type(obj)}" + + +def _find_backrefs_not_to_self(_object: Any) -> list[str]: + import objgraph # pylint: disable=import-outside-toplevel + + return [ + _safe_repr(backref) + for backref in objgraph.find_backref_chain( + _object, lambda obj: obj is not _object + ) + ] + + +def _log_object_sources( + max_objects: int, last_ids: set[int], last_stats: dict[str, int] +) -> None: + # Imports deferred to avoid loading modules + # in memory since usually only one part of this + # integration is used at a time + import gc # pylint: disable=import-outside-toplevel + + gc.collect() + + objects = gc.get_objects() + new_objects: list[object] = [] + new_objects_overflow: dict[str, int] = {} + current_ids = set() + new_stats: dict[str, int] = {} + had_new_object_growth = False + try: + for _object in objects: + object_type = type(_object).__name__ + new_stats[object_type] = new_stats.get(object_type, 0) + 1 + + for _object in objects: + id_ = id(_object) + current_ids.add(id_) + if id_ in last_ids: + continue + object_type = type(_object).__name__ + if last_stats.get(object_type, 0) < new_stats[object_type]: + if len(new_objects) < max_objects: + new_objects.append(_object) + else: + new_objects_overflow.setdefault(object_type, 0) + new_objects_overflow[object_type] += 1 + + for _object in new_objects: + had_new_object_growth = True + object_type = type(_object).__name__ + _LOGGER.critical( + "New object %s (%s/%s) at %s: %s", + object_type, + last_stats.get(object_type, 0), + new_stats[object_type], + _get_function_absfile(_object) or _find_backrefs_not_to_self(_object), + _safe_repr(_object), + ) + + for object_type, count in last_stats.items(): + new_stats[object_type] = max(new_stats.get(object_type, 0), count) + finally: + # Break reference cycles + del objects + del new_objects + last_ids.clear() + last_ids.update(current_ids) + last_stats.clear() + last_stats.update(new_stats) + del new_stats + del current_ids + + if new_objects_overflow: + _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) + elif not had_new_object_growth: + _LOGGER.critical("No new object growth found") diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 1105842891ff..3bd6d7636ac6 100644 --- a/homeassistant/components/profiler/services.yaml +++ b/homeassistant/components/profiler/services.yaml @@ -25,7 +25,7 @@ memory: max: 3600 unit_of_measurement: seconds start_log_objects: - name: Start log objects + name: Start logging objects description: Start logging growth of objects in memory fields: scan_interval: @@ -38,7 +38,7 @@ start_log_objects: max: 3600 unit_of_measurement: seconds stop_log_objects: - name: Stop log objects + name: Stop logging objects description: Stop logging growth of objects in memory. dump_log_objects: name: Dump log objects @@ -51,6 +51,31 @@ dump_log_objects: example: State selector: text: +start_log_object_sources: + name: Start logging object sources + description: Start logging sources of new objects in memory + fields: + scan_interval: + name: Scan interval + description: The number of seconds between logging objects. + default: 30.0 + selector: + number: + min: 1 + max: 3600 + unit_of_measurement: seconds + max_objects: + name: Maximum objects + description: The maximum number of objects to log. + default: 5 + selector: + number: + min: 1 + max: 30 + unit_of_measurement: objects +stop_log_object_sources: + name: Stop logging object sources + description: Stop logging sources of new objects in memory. lru_stats: name: Log LRU stats description: Log the stats of all lru caches. diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 9466660dca40..0cafa9ed7aae 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -19,7 +19,9 @@ from homeassistant.components.profiler import ( SERVICE_LRU_STATS, SERVICE_MEMORY, SERVICE_START, + SERVICE_START_LOG_OBJECT_SOURCES, SERVICE_START_LOG_OBJECTS, + SERVICE_STOP_LOG_OBJECT_SOURCES, SERVICE_STOP_LOG_OBJECTS, ) from homeassistant.components.profiler.const import DOMAIN @@ -130,13 +132,20 @@ async def test_object_growth_logging( await hass.services.async_call( DOMAIN, SERVICE_START_LOG_OBJECTS, {CONF_SCAN_INTERVAL: 10}, blocking=True ) + with pytest.raises(HomeAssistantError, match="Object logging already started"): + await hass.services.async_call( + DOMAIN, + SERVICE_START_LOG_OBJECTS, + {CONF_SCAN_INTERVAL: 10}, + blocking=True, + ) - assert "Growth" in caplog.text - caplog.clear() + assert "Growth" in caplog.text + caplog.clear() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=11)) - await hass.async_block_till_done() - assert "Growth" in caplog.text + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=11)) + await hass.async_block_till_done() + assert "Growth" in caplog.text await hass.services.async_call(DOMAIN, SERVICE_STOP_LOG_OBJECTS, {}, blocking=True) caplog.clear() @@ -145,6 +154,17 @@ async def test_object_growth_logging( await hass.async_block_till_done() assert "Growth" not in caplog.text + with pytest.raises(HomeAssistantError, match="Object logging not running"): + await hass.services.async_call( + DOMAIN, SERVICE_STOP_LOG_OBJECTS, {}, blocking=True + ) + + with patch("objgraph.growth"): + await hass.services.async_call( + DOMAIN, SERVICE_START_LOG_OBJECTS, {CONF_SCAN_INTERVAL: 10}, blocking=True + ) + caplog.clear() + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -276,3 +296,92 @@ async def test_lru_stats(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) assert "_dummy_test_lru_stats" in caplog.text assert "CacheInfo" in caplog.text assert "sqlalchemy_test" in caplog.text + + +async def test_log_object_sources( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we can setup and the service and we can dump objects to the log.""" + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, SERVICE_START_LOG_OBJECT_SOURCES) + assert hass.services.has_service(DOMAIN, SERVICE_STOP_LOG_OBJECT_SOURCES) + + class FakeObject: + """Fake object.""" + + def __repr__(self): + """Return a fake repr."".""" + return "" + + fake_object = FakeObject() + + with patch("gc.collect"), patch("gc.get_objects", return_value=[fake_object]): + await hass.services.async_call( + DOMAIN, + SERVICE_START_LOG_OBJECT_SOURCES, + {CONF_SCAN_INTERVAL: 10}, + blocking=True, + ) + with pytest.raises(HomeAssistantError, match="Object logging already started"): + await hass.services.async_call( + DOMAIN, + SERVICE_START_LOG_OBJECT_SOURCES, + {CONF_SCAN_INTERVAL: 10}, + blocking=True, + ) + + assert "New object FakeObject (0/1)" in caplog.text + caplog.clear() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=11)) + await hass.async_block_till_done() + assert "No new object growth found" in caplog.text + + fake_object2 = FakeObject() + + with patch("gc.collect"), patch( + "gc.get_objects", return_value=[fake_object, fake_object2] + ): + caplog.clear() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=21)) + await hass.async_block_till_done() + assert "New object FakeObject (1/2)" in caplog.text + + many_objects = [FakeObject() for _ in range(30)] + with patch("gc.collect"), patch("gc.get_objects", return_value=many_objects): + caplog.clear() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + assert "New object FakeObject (2/30)" in caplog.text + assert "New objects overflowed by {'FakeObject': 25}" in caplog.text + + await hass.services.async_call( + DOMAIN, SERVICE_STOP_LOG_OBJECT_SOURCES, {}, blocking=True + ) + caplog.clear() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=41)) + await hass.async_block_till_done() + assert "FakeObject" not in caplog.text + assert "No new object growth found" not in caplog.text + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=51)) + await hass.async_block_till_done() + assert "FakeObject" not in caplog.text + assert "No new object growth found" not in caplog.text + + with pytest.raises(HomeAssistantError, match="Object logging not running"): + await hass.services.async_call( + DOMAIN, SERVICE_STOP_LOG_OBJECT_SOURCES, {}, blocking=True + ) From 89230b75be3aea744ec5a40cea17fb14e8183fdc Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 20:25:38 +0200 Subject: [PATCH 0992/1058] Add entity name translations to GIOS (#90655) * Add entity name translations * Update tests --- homeassistant/components/gios/sensor.py | 20 ++++-------- homeassistant/components/gios/strings.json | 27 +++++++++++++++ tests/components/gios/test_sensor.py | 38 +++++++++++----------- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 7cf4b7e7c600..f078cc074e9c 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -60,7 +60,6 @@ class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKey SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, - name="AQI", value=lambda sensors: sensors.aqi.value if sensors.aqi else None, icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, @@ -69,35 +68,34 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_C6H6, - name="C6H6", value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="c6h6", ), GiosSensorEntityDescription( key=ATTR_CO, - name="CO", value=lambda sensors: sensors.co.value if sensors.co else None, suggested_display_precision=0, icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="co", ), GiosSensorEntityDescription( key=ATTR_NO2, - name="NO2", value=lambda sensors: sensors.no2.value if sensors.no2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="no2", ), GiosSensorEntityDescription( key=ATTR_NO2, subkey="index", - name="NO2 index", value=lambda sensors: sensors.no2.index if sensors.no2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -106,17 +104,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_O3, - name="O3", value=lambda sensors: sensors.o3.value if sensors.o3 else None, suggested_display_precision=0, device_class=SensorDeviceClass.OZONE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="o3", ), GiosSensorEntityDescription( key=ATTR_O3, subkey="index", - name="O3 index", value=lambda sensors: sensors.o3.index if sensors.o3 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -125,17 +122,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_PM10, - name="PM10", value=lambda sensors: sensors.pm10.value if sensors.pm10 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm10", ), GiosSensorEntityDescription( key=ATTR_PM10, subkey="index", - name="PM10 index", value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -144,17 +140,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_PM25, - name="PM2.5", value=lambda sensors: sensors.pm25.value if sensors.pm25 else None, suggested_display_precision=0, device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="pm25", ), GiosSensorEntityDescription( key=ATTR_PM25, subkey="index", - name="PM2.5 index", value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, @@ -163,17 +158,16 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( ), GiosSensorEntityDescription( key=ATTR_SO2, - name="SO2", value=lambda sensors: sensors.so2.value if sensors.so2 else None, suggested_display_precision=0, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, + translation_key="so2", ), GiosSensorEntityDescription( key=ATTR_SO2, subkey="index", - name="SO2 index", value=lambda sensors: sensors.so2.index if sensors.so2 else None, icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index 53e7dd78a8f9..bbbd1c3e6ccb 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -26,6 +26,7 @@ "entity": { "sensor": { "aqi": { + "name": "AQI", "state": { "very_bad": "Very bad", "bad": "Bad", @@ -35,7 +36,17 @@ "very_good": "Very good" } }, + "c6h6": { + "name": "Benzene" + }, + "co": { + "name": "Carbon monoxide" + }, + "no2": { + "name": "Nitrogen dioxide" + }, "no2_index": { + "name": "Nitrogen dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -45,7 +56,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "o3": { + "name": "Ozone" + }, "o3_index": { + "name": "Ozone index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -55,7 +70,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm10": { + "name": "PM10" + }, "pm10_index": { + "name": "PM10 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -65,7 +84,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "pm25": { + "name": "PM2.5" + }, "pm25_index": { + "name": "PM2.5 index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", @@ -75,7 +98,11 @@ "very_good": "[%key:component::gios::entity::sensor::aqi::state::very_good%]" } }, + "so2": { + "name": "Sulphur dioxide" + }, "so2_index": { + "name": "Sulphur dioxide index", "state": { "very_bad": "[%key:component::gios::entity::sensor::aqi::state::very_bad%]", "bad": "[%key:component::gios::entity::sensor::aqi::state::bad%]", diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py index 48f0e2384011..2eb74ec12196 100644 --- a/tests/components/gios/test_sensor.py +++ b/tests/components/gios/test_sensor.py @@ -35,7 +35,7 @@ async def test_sensor(hass: HomeAssistant) -> None: await init_integration(hass) registry = er.async_get(hass) - state = hass.states.get("sensor.home_c6h6") + state = hass.states.get("sensor.home_benzene") assert state assert state.state == "0.23789" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -46,11 +46,11 @@ async def test_sensor(hass: HomeAssistant) -> None: ) assert state.attributes.get(ATTR_ICON) == "mdi:molecule" - entry = registry.async_get("sensor.home_c6h6") + entry = registry.async_get("sensor.home_benzene") assert entry assert entry.unique_id == "123-c6h6" - state = hass.states.get("sensor.home_co") + state = hass.states.get("sensor.home_carbon_monoxide") assert state assert state.state == "251.874" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -61,11 +61,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_co") + entry = registry.async_get("sensor.home_carbon_monoxide") assert entry assert entry.unique_id == "123-co" - state = hass.states.get("sensor.home_no2") + state = hass.states.get("sensor.home_nitrogen_dioxide") assert state assert state.state == "7.13411" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -76,11 +76,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_no2") + entry = registry.async_get("sensor.home_nitrogen_dioxide") assert entry assert entry.unique_id == "123-no2" - state = hass.states.get("sensor.home_no2_index") + state = hass.states.get("sensor.home_nitrogen_dioxide_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -94,11 +94,11 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_no2_index") + entry = registry.async_get("sensor.home_nitrogen_dioxide_index") assert entry assert entry.unique_id == "123-no2-index" - state = hass.states.get("sensor.home_o3") + state = hass.states.get("sensor.home_ozone") assert state assert state.state == "95.7768" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -109,11 +109,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_o3") + entry = registry.async_get("sensor.home_ozone") assert entry assert entry.unique_id == "123-o3" - state = hass.states.get("sensor.home_o3_index") + state = hass.states.get("sensor.home_ozone_index") assert state assert state.state == "good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -127,7 +127,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_o3_index") + entry = registry.async_get("sensor.home_ozone_index") assert entry assert entry.unique_id == "123-o3-index" @@ -197,7 +197,7 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "123-pm25-index" - state = hass.states.get("sensor.home_so2") + state = hass.states.get("sensor.home_sulphur_dioxide") assert state assert state.state == "4.35478" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -208,11 +208,11 @@ async def test_sensor(hass: HomeAssistant) -> None: == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) - entry = registry.async_get("sensor.home_so2") + entry = registry.async_get("sensor.home_sulphur_dioxide") assert entry assert entry.unique_id == "123-so2" - state = hass.states.get("sensor.home_so2_index") + state = hass.states.get("sensor.home_sulphur_dioxide_index") assert state assert state.state == "very_good" assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -226,7 +226,7 @@ async def test_sensor(hass: HomeAssistant) -> None: "very_good", ] - entry = registry.async_get("sensor.home_so2_index") + entry = registry.async_get("sensor.home_sulphur_dioxide_index") assert entry assert entry.unique_id == "123-so2-index" @@ -341,11 +341,11 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: """Test states of the sensor when API returns invalid indexes.""" await init_integration(hass, invalid_indexes=True) - state = hass.states.get("sensor.home_no2_index") + state = hass.states.get("sensor.home_nitrogen_dioxide_index") assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_o3_index") + state = hass.states.get("sensor.home_ozone_index") assert state assert state.state == STATE_UNAVAILABLE @@ -357,7 +357,7 @@ async def test_invalid_indexes(hass: HomeAssistant) -> None: assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get("sensor.home_so2_index") + state = hass.states.get("sensor.home_sulphur_dioxide_index") assert state assert state.state == STATE_UNAVAILABLE From 90de51fff31c59077e9a2dbf6e0e0c3cdcc0080d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Apr 2023 20:24:40 +0200 Subject: [PATCH 0993/1058] Add entity name translations to Airly (#90656) Add entity name translations --- homeassistant/components/airly/sensor.py | 22 ++++++------ homeassistant/components/airly/strings.json | 37 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 754471c9d8b0..53e15c651a7b 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -68,7 +68,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_CAQI, icon="mdi:air-filter", - name=ATTR_API_CAQI, + translation_key="caqi", native_unit_of_measurement="CAQI", suggested_display_precision=0, attrs=lambda data: { @@ -80,7 +80,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM1, device_class=SensorDeviceClass.PM1, - name="PM1.0", + translation_key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -88,7 +88,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM25, device_class=SensorDeviceClass.PM25, - name="PM2.5", + translation_key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -100,7 +100,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PM10, device_class=SensorDeviceClass.PM10, - name=ATTR_API_PM10, + translation_key="pm10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -112,7 +112,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, - name=ATTR_API_HUMIDITY.capitalize(), + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, @@ -120,7 +120,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_PRESSURE, device_class=SensorDeviceClass.PRESSURE, - name=ATTR_API_PRESSURE.capitalize(), + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -128,14 +128,14 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, - name=ATTR_API_TEMPERATURE.capitalize(), + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, ), AirlySensorEntityDescription( key=ATTR_API_CO, - name="Carbon monoxide", + translation_key="co", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -147,7 +147,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_NO2, device_class=SensorDeviceClass.NITROGEN_DIOXIDE, - name="Nitrogen dioxide", + translation_key="no2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -159,7 +159,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_SO2, device_class=SensorDeviceClass.SULPHUR_DIOXIDE, - name="Sulphur dioxide", + translation_key="so2", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -171,7 +171,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( AirlySensorEntityDescription( key=ATTR_API_O3, device_class=SensorDeviceClass.OZONE, - name="Ozone", + translation_key="o3", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 4f95f26afc09..93fcffa571eb 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -26,5 +26,42 @@ "requests_remaining": "Remaining allowed requests", "requests_per_day": "Allowed requests per day" } + }, + "entity": { + "sensor": { + "caqi": { + "name": "CAQI" + }, + "pm1": { + "name": "PM1.0" + }, + "pm25": { + "name": "PM2.5" + }, + "pm10": { + "name": "PM10" + }, + "humidity": { + "name": "Humidity" + }, + "pressure": { + "name": "Pressure" + }, + "temperature": { + "name": "Temperature" + }, + "co": { + "name": "Carbon monoxide" + }, + "no2": { + "name": "Nitrogen dioxide" + }, + "so2": { + "name": "Sulphur dioxide" + }, + "o3": { + "name": "Ozone" + } + } } } From 5e5888b37a5558f634cc92c083335175839551d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 08:09:44 -1000 Subject: [PATCH 0994/1058] Bump zeroconf to 0.52.0 (#90660) * Bump zeroconf to 0.52.0 Switch to using the new ip_addresses_by_version which avoids all the ip address conversions * updates --- homeassistant/components/zeroconf/__init__.py | 37 +++++-------------- .../components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 13 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index badc1242714b..a3a055b29c7e 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -564,14 +564,19 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if isinstance(value, bytes): properties[key] = value.decode("utf-8") - if not (addresses := service.addresses or service.parsed_addresses()): + if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - if (host := _first_non_link_local_address(addresses)) is None: + host: str | None = None + for ip_addr in ip_addresses: + if not ip_addr.is_link_local and not ip_addr.is_unspecified: + host = str(ip_addr) + break + if not host: return None return ZeroconfServiceInfo( - host=str(host), - addresses=service.parsed_addresses(), + host=host, + addresses=[str(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, @@ -580,30 +585,6 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: ) -def _first_non_link_local_address( - addresses: list[bytes] | list[str], -) -> str | None: - """Return the first ipv6 or non-link local ipv4 address, preferring IPv4.""" - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 4 - ): - return str(ip_addr) - # If we didn't find a good IPv4 address, check for IPv6 addresses. - for address in addresses: - ip_addr = ip_address(address) - if ( - not ip_addr.is_link_local - and not ip_addr.is_unspecified - and ip_addr.version == 6 - ): - return str(ip_addr) - return None - - def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 36c2fcc12791..09fc07684c54 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.51.0"] + "requirements": ["zeroconf==0.52.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4763b3ab948c..8c77bf0620c1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.51.0 +zeroconf==0.52.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 8ecf19d47fd5..be5d1de874cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.51.0 +zeroconf==0.52.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5229a53734a8..da89a4196540 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,7 +1929,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.51.0 +zeroconf==0.52.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 8fe7b01baacba743ec78ac7dd18f64642fbcd102 Mon Sep 17 00:00:00 2001 From: Patrick ZAJDA Date: Mon, 3 Apr 2023 02:19:03 +0200 Subject: [PATCH 0995/1058] Add entity name translations for Nest sensors (#90677) Signed-off-by: Patrick ZAJDA --- homeassistant/components/nest/sensor_sdm.py | 4 ++-- homeassistant/components/nest/strings.json | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 187ac0ee8c2a..8eb607b20566 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -79,7 +79,7 @@ class TemperatureSensor(SensorBase): _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_name = "Temperature" + _attr_translation_key = "temperature" @property def native_value(self) -> float: @@ -96,7 +96,7 @@ class HumiditySensor(SensorBase): _attr_device_class = SensorDeviceClass.HUMIDITY _attr_native_unit_of_measurement = PERCENTAGE - _attr_name = "Humidity" + _attr_translation_key = "humidity" @property def native_value(self) -> int: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index bf68d1988d63..c0c7042423bc 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -98,5 +98,15 @@ "title": "Nest Authentication Credentials must be updated", "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." } + }, + "entity": { + "sensor": { + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + } + } } } From 77bc745bed3d1a24ea25f333c1bc18113a6dcf69 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Apr 2023 14:28:52 -0400 Subject: [PATCH 0996/1058] Fix frontend test (#90679) --- tests/components/frontend/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 69643b10ec2f..dcff80d35947 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -141,7 +141,7 @@ async def test_frontend_and_static(mock_http_client, mock_onboarded) -> None: text = await resp.text() # Test we can retrieve frontend.js - frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9]{8}.js)", text) + frontendjs = re.search(r"(?P\/frontend_es5\/app.[A-Za-z0-9_]{11}.js)", text) assert frontendjs is not None, text resp = await mock_http_client.get(frontendjs.groups(0)[0]) @@ -546,7 +546,7 @@ async def test_auth_authorize(mock_http_client) -> None: # Test we can retrieve authorize.js authorizejs = re.search( - r"(?P\/frontend_latest\/authorize.[A-Za-z0-9]{8}.js)", text + r"(?P\/frontend_latest\/authorize.[A-Za-z0-9_]{11}.js)", text ) assert authorizejs is not None, text From 6d967ac5357b639b41ac91ae713717ff8b5b17cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 13:32:00 -1000 Subject: [PATCH 0997/1058] Bump zeroconf to 0.53.0 (#90682) --- homeassistant/components/zeroconf/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/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 09fc07684c54..551471b41e08 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.52.0"] + "requirements": ["zeroconf==0.53.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8c77bf0620c1..a60e35d963da 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ ulid-transform==0.5.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 yarl==1.8.1 -zeroconf==0.52.0 +zeroconf==0.53.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index be5d1de874cc..2790f5021457 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2695,7 +2695,7 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.52.0 +zeroconf==0.53.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da89a4196540..381613f15d65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,7 +1929,7 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.52.0 +zeroconf==0.53.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 83b7018be24afd4aa9f6c8c085b295a33d192c0f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 3 Apr 2023 02:53:00 +0200 Subject: [PATCH 0998/1058] Fix default sensor entity name for PM1 (#90684) Fix PM1 text --- homeassistant/components/sensor/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 16e0da0d5182..262f7033a415 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -197,7 +197,7 @@ "name": "Ozone" }, "pm1": { - "name": "Particulate matter 0.1 μm" + "name": "Particulate matter 1 μm" }, "pm10": { "name": "Particulate matter 10 μm" From e10e3ee7cc3e37746242dd2584b24998db3a340f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Apr 2023 14:51:25 -1000 Subject: [PATCH 0999/1058] Fix memory churn in state templates (#90685) * Fix memory churn in state templates The LRU for state templates was limited to 512 states. As soon as it was exaused, system performance would tank as each template that iterated all states would have to create and GC any state > 512 * does it scale? * avoid copy on all * comment * preen * cover * cover * comments * comments * comments * preen * preen --- homeassistant/bootstrap.py | 1 + homeassistant/helpers/template.py | 98 +++++++++++++++++++++++++++---- tests/helpers/test_template.py | 40 ++++++++++++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 445ff35793c9..d98680c70d4c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -239,6 +239,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: # Load the registries and cache the result of platform.uname().processor entity.async_setup(hass) + template.async_setup(hass) await asyncio.gather( area_registry.async_load(hass), device_registry.async_load(hass), diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8e5951488ba0..fb693d6957d1 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, Collection, Generator, Iterable, MutableMapping from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -41,6 +41,7 @@ from jinja2 import pass_context, pass_environment, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace +from lru import LRU # pylint: disable=no-name-in-module import voluptuous as vol from homeassistant.const import ( @@ -49,6 +50,8 @@ from homeassistant.const import ( ATTR_LONGITUDE, ATTR_PERSONS, ATTR_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfLength, @@ -121,11 +124,77 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) +# +# CACHED_TEMPLATE_STATES is a rough estimate of the number of entities +# on a typical system. It is used as the initial size of the LRU cache +# for TemplateState objects. +# +# If the cache is too small we will end up creating and destroying +# TemplateState objects too often which will cause a lot of GC activity +# and slow down the system. For systems with a lot of entities and +# templates, this can reach 100000s of object creations and destructions +# per minute. +# +# Since entity counts may grow over time, we will increase +# the size if the number of entities grows via _async_adjust_lru_sizes +# at the start of the system and every 10 minutes if needed. +# CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 +CACHED_TEMPLATE_LRU: MutableMapping[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) +CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( + CACHED_TEMPLATE_STATES +) +ENTITY_COUNT_GROWTH_FACTOR = 1.2 + + +def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state without collecting.""" + if template_state := CACHED_TEMPLATE_NO_COLLECT_LRU.get(state): + return template_state + template_state = _create_template_state_no_collect(hass, state) + CACHED_TEMPLATE_NO_COLLECT_LRU[state] = template_state + return template_state + + +def _template_state(hass: HomeAssistant, state: State) -> TemplateState: + """Return a TemplateState for a state that collects.""" + if template_state := CACHED_TEMPLATE_LRU.get(state): + return template_state + template_state = TemplateState(hass, state) + CACHED_TEMPLATE_LRU[state] = template_state + return template_state + + +def async_setup(hass: HomeAssistant) -> bool: + """Set up tracking the template LRUs.""" + + @callback + def _async_adjust_lru_sizes(_: Any) -> None: + """Adjust the lru cache sizes.""" + new_size = int( + round(hass.states.async_entity_ids_count() * ENTITY_COUNT_GROWTH_FACTOR) + ) + for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): + # There is no typing for LRU + current_size = lru.get_size() # type: ignore[attr-defined] + if new_size > current_size: + lru.set_size(new_size) # type: ignore[attr-defined] + + from .event import ( # pylint: disable=import-outside-toplevel + async_track_time_interval, + ) + + cancel = async_track_time_interval( + hass, _async_adjust_lru_sizes, timedelta(minutes=10) + ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_adjust_lru_sizes) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, callback(lambda _: cancel())) + return True + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -969,21 +1038,33 @@ class TemplateStateFromEntityId(TemplateStateBase): return f"