From cebc6dd096b192d0d75a2b6263903a200ecfc9f6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Feb 2023 20:44:37 +0100 Subject: [PATCH 001/127] Bumped version to 2023.3.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 52cb0b5fa0f..f1670bf3d35 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 = 3 -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 d913ad0daaf..7102fe5d92c 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.3.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 45547d226eba0bd1d83ef26667e0f3a81fb29ead 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 002/127] 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 8a773213a58..4c10a2328a2 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 128e3b145f1..2f1b22e0ea2 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 b96acb29b10..adff43d377b 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 0d2006bf3338cb712391092cb010da7545ee6981 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 003/127] 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 2612d2d4f68..091de1949eb 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 f988e72e70b..4e99d19261b 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 2db8d4b73a9ad5edfc34fd6d05f45c3f72533f16 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Feb 2023 21:31:02 +0100 Subject: [PATCH 004/127] 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 7abf716cec4..24fb89f2140 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 89b5aa3baae..16fadd9b06e 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 7d0e175bde3..0de24f6b3cb 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 425c87c7a85..b1d1176f160 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 918d1504653..b0bd8956c24 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -95,7 +95,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", @@ -199,7 +199,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']}", @@ -248,7 +248,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 cfaba87dd6205176bf92810139c16542122f9268 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Feb 2023 14:58:11 -0500 Subject: [PATCH 005/127] 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 19eaa55f00e..ebe2ab00257 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 1d54084969b..00aae5b8a07 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 b0bd8956c24..e27cfb219cf 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 10affab0786..7818d736e0e 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 35142e456a13627fd5a7cd95709272d078fbdbfc Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 23 Feb 2023 08:13:03 +0100 Subject: [PATCH 006/127] 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 2d3fc52eb30..6633f5c02f2 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 afba72fbf10..62b2b5a038e 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 71ca16ca68d..51a96977172 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 0de24f6b3cb..80c7bedb6d0 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 b1d1176f160..4583dbae11d 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 951df3df5721bbd07d89971db31219b8645b5813 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Feb 2023 02:18:55 -0500 Subject: [PATCH 007/127] 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 64d24504ef7..718a6b571af 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 f3d086afed0..4a937585732 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 1a817c8a526..b8d9ad3d16f 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 80c7bedb6d0..5f857958a74 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 4583dbae11d..be5f3bb7f39 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 2fddbcedcffc8bdc4eae420bbb4b4f9be43c5005 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 23 Feb 2023 10:37:15 -0800 Subject: [PATCH 008/127] 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 390e14d1689..c77d6c9c67a 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 be6fb4a17b5..88737150c02 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 806410c9834..5c90a1cfc2c 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 c7eea20920f..f432fe3f977 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 0fb28dcf9e304085dd5434542ee109861f51bec6 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 009/127] 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 7d9e8b9a4fd..0f59c6279fb 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 7b5a76c9ace..ddc7d3ce85d 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 5adf1dcc90d0dad00c558f10f84a2f16e83e0534 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 23 Feb 2023 20:58:37 +0100 Subject: [PATCH 010/127] 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 111e7c0ea96..e86e5c0ca49 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 5bcec4b433d..fbc027091b4 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 ce5b7a10916..b4d1b867e77 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 35b5d40b6da..00000000000 --- 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 00000000000..3fb8481dc94 --- /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 4a0c8f6a603..a1d67158ab0 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 994ab0ff80c..4b609950256 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 a891870bbef..da0739cd417 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 73863de5bdb..b81ac2c62b8 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 00000000000..3ce5f184672 --- /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 d60d473b0be..34760fbbf13 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 53ae25f8891..e5c98610439 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 5f857958a74..dea4022c983 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 be5f3bb7f39..00bba24ec31 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 4f45862c5cb..172290125b8 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 8f849c85941..2ccb818b333 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 a5a858b0b11..cab1f59f837 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 deaaf62c972..24b6662108c 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 25f066d4768217dab0d7ae17cbe80aa9b70bf244 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 23 Feb 2023 19:38:07 +0100 Subject: [PATCH 011/127] 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 17cbbb72efe..a5930177b9c 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 5193492a075..4675a2ae923 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 dea4022c983..9b25fbc4821 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 00bba24ec31..a48a0bb3019 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 70e1d14da0aa769f5df00cd3961506a3d0caf745 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 23 Feb 2023 15:00:13 -0500 Subject: [PATCH 012/127] Bumped version to 2023.3.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 f1670bf3d35..8c80e725193 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 = 3 -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 7102fe5d92c..15a62106b42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0b0" +version = "2023.3.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 74696a3fac3f6e3f2566330877d58750121f4448 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 013/127] 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 554a94b8450..05dc67314ed 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 090e171835d..c3aefe5987a 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 0f7363bb011..d9556451996 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 64ad5326ddfe8b7a8b543b70203d79396650a0e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Feb 2023 19:52:31 -0600 Subject: [PATCH 014/127] 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 f4b82be0ace..71104192153 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 9b25fbc4821..1922d45bf39 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 a48a0bb3019..826325f1848 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 389400cc511..3446b1dc66b 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 7e2a81d3100..626aa44efd4 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 02bd3f897d81db387c6899c7f4e5afd6daf1d1a5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 23 Feb 2023 19:50:23 -0600 Subject: [PATCH 015/127] 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 2c531fea14d..114c71f53c8 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 e69271cb4682e41a2acd551da915616300aa6051 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 10:09:12 -0600 Subject: [PATCH 016/127] 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 94b111ab420..fde8c26ba5e 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 1922d45bf39..db304e946e3 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 826325f1848..dac4e5f0605 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 0c6a4692189785a47f9f329ad34e2e49d6cf83dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 11:41:44 -0600 Subject: [PATCH 017/127] 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 a3a609a1b6f..431bc78ba80 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 5cda3d283dd..3ff6b62b21e 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 2d6f84b2a84facdfb47b39082e9d01ee9829b93b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 13:33:25 -0600 Subject: [PATCH 018/127] 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 85598815c2c..ef48a5988a3 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 08768e8bacc..ce911183dfd 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 5a2d7a5dd4336267868b9184ba476a1ad9f68d24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 20:37:36 -0600 Subject: [PATCH 019/127] 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 38afa37838a..c15436ed2c1 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 64197aa5f5a9cf1b609fc8eba1952496abe2e746 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 25 Feb 2023 03:39:59 +0100 Subject: [PATCH 020/127] 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 a5930177b9c..1daffd43076 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 4675a2ae923..08cccaf7b5f 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 db304e946e3..c7c2bf6f147 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 dac4e5f0605..92e58a0a825 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 b6e50135f51847e0dcef732e1401261aa28c5c5b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 24 Feb 2023 21:41:02 -0500 Subject: [PATCH 021/127] Bumped version to 2023.3.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 8c80e725193..60abfd52a87 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 = 3 -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 15a62106b42..125b8eaafe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0b1" +version = "2023.3.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2405908cdda9485e46554bcb1855884372382984 Mon Sep 17 00:00:00 2001 From: Arturo Date: Sat, 25 Feb 2023 03:25:04 -0600 Subject: [PATCH 022/127] 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 da0739cd417..080cc472f2d 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 cab1f59f837..226b22670e6 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 d5b0c1faa0c8fda9e0d1a3268f0da8ac5bcf6a99 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 023/127] 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 e2f188541d7..17825110490 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 c7c2bf6f147..bb0fdc5c564 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 92e58a0a825..dbad01d921f 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 4ef96c76e4f5ba15f67e6e493d2ff9087b30feb0 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 024/127] 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 7f889459977..0d2dc06b83f 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 0e3e954000a44db0341224d2e452a47e2e0e63d2 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sat, 25 Feb 2023 00:20:17 -0800 Subject: [PATCH 025/127] 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 a820a7a034a..8e0d58b7b77 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 bb0fdc5c564..ae13c59367a 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 dbad01d921f..bc013519023 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 ca539d0a09153417d39e17e6f0a5bcad2d68e5d9 Mon Sep 17 00:00:00 2001 From: mkmer Date: Fri, 24 Feb 2023 21:49:49 -0500 Subject: [PATCH 026/127] 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 87f3e025917..73986920b8a 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 833ccafb76b7c2170f50c6962d3ea634558d32ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Feb 2023 05:01:30 -0600 Subject: [PATCH 027/127] 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 a3f8711c8ea..7268b7d8f24 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 6196607c5dd84fe3402346e970db1b19df863fdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Feb 2023 22:11:48 -0600 Subject: [PATCH 028/127] 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 9fd643a7757..c7035d5a0d2 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 ed8f538890d2cdd7fd4243e5e18712e3a4c4b1f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Feb 2023 05:02:07 -0600 Subject: [PATCH 029/127] 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 2bfccf46960..f7e78e82fb4 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 3b20782f5b4..9f1d8dfcbc9 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 91a03ab83da14d927efc654909d0d9e332219edc 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 030/127] 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 c3aefe5987a..a36373c8625 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 cadb007e12c..9f8398d4930 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 2063dbf00ddd5a98ac67eb9e88ca8159ebc1b7d1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 25 Feb 2023 12:07:47 +0100 Subject: [PATCH 031/127] Bumped version to 2023.3.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 60abfd52a87..9f0249e9921 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 = 3 -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 125b8eaafe6..37d3b7f021c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0b2" +version = "2023.3.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5b78e0c4ffe61f22fcbd75156a2cbc41b5a37cf2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:36:18 -0600 Subject: [PATCH 032/127] 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 7268b7d8f24..7003b87ce67 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 81c9fb81868..b07a17972f7 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 4749daa0c0b..eb81efae920 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 aaa5bb9f8606c87a9feaf97e196e9ffbf28a6b56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Feb 2023 21:47:18 -0600 Subject: [PATCH 033/127] 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 c2c84bf855d..45ceb471fd8 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 8c7b29db254751b0f29c7f313eaa981e9646d394 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 26 Feb 2023 11:05:31 +0100 Subject: [PATCH 034/127] 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 57c5f680e01..fd77b5e2344 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 89c993cafaa..263fd41b309 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 6050010b20d..434a9a50ea6 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 d9a2bd365e9..5114cc222e9 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 579b8e79156..79078811881 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 d554eaf4ff0..e255ff36500 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 94f37040486..8aabad2c9fc 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 23634e77c52..95d96de9764 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 ae13c59367a..7a24f87ef92 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 bc013519023..4264ae79560 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 0ae9c73e6b8..b75c49b2b79 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 0ced1799b48..e4f90a59f67 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 9263919214d..3360c82577f 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 fe181425d840a881077e3e49c11910f94a771073 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Feb 2023 20:25:29 -0500 Subject: [PATCH 035/127] 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 bbc50fe7a58..70fc024c005 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 a36373c8625..1e0d8999d30 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 9f8398d4930..8d2f179aef4 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 a7e081f70d47342c0412aadc13ad08cc7d219746 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Feb 2023 21:45:14 +0100 Subject: [PATCH 036/127] 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 51a96977172..5752afc92ac 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 e48089e0c9d8872aeb809361b335bae2d0bebd93 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 26 Feb 2023 21:49:24 +0100 Subject: [PATCH 037/127] 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 6633f5c02f2..2faa89232af 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 0449856064cf7104e5aebf47811c94be0540a2b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 20:06:27 -0600 Subject: [PATCH 038/127] 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 718a6b571af..dedfc9127a3 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 b8d9ad3d16f..e34ace05e15 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 7a24f87ef92..358caaf8537 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 4264ae79560..c20cbe6a596 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 d8850758f143eb696bcc26432677be218f8166b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 20:08:20 -0600 Subject: [PATCH 039/127] 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 4e659d39cc5..96d31872d0b 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 1828687c0dd..ea3730fa3e3 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 1c348fc0086..854109bee6d 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 2fff836bd4b61afefe9ba7a9c54f4f589436046a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 17:59:28 -0600 Subject: [PATCH 040/127] 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 86a63538a68..c68d99bfb22 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 3c3da10db7c..9f6f65f1d2d 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 ab6bd75b700ae34675ba6d830565b27142286f18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 20:14:54 -0600 Subject: [PATCH 041/127] 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 7d7ef2d42bf..86b73c762fb 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 1503674bd6b20e8d6136f41a39b43ffa575e67c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:01:02 -0600 Subject: [PATCH 042/127] 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 df4cb651528..bbea3f1d5f8 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 aecdc79da92..12b77aded8f 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 95e337277c78e266a4acf231eac5a498d52ef8f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Feb 2023 21:02:52 -0600 Subject: [PATCH 043/127] 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 78e713ce5e6..d5cf65d8724 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 b91ac2cbf4d..aabc27ff14e 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 26697219ae5..2686138d724 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 a8dec3cca27..83ad809016a 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 fbe1524f6c264e3c93521cd5ff4d1e562b14cb46 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 26 Feb 2023 22:37:34 -0500 Subject: [PATCH 044/127] Bumped version to 2023.3.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 9f0249e9921..541b5e6a967 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 = 3 -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 37d3b7f021c..121f3726ca3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0b3" +version = "2023.3.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6512cd901fb069e2813ce8e5abd4b3b532caffe8 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 045/127] 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 cf83db4f9c0..354656ecd9e 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 f2e4943a536915f376c95fcf0138f608cdd94786 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 15:29:14 +0100 Subject: [PATCH 046/127] 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 bbea3f1d5f8..94f2bab75ac 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 9740d338eff..2377f47d7e9 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 d26f43076663bbdb3113438314a4017ba14c9537 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 27 Feb 2023 07:48:23 -0500 Subject: [PATCH 047/127] 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 02bb95c38f6..4b8e73e9fe7 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 358caaf8537..3308b8d9425 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 c20cbe6a596..b9c4ff1b19e 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 d399855e50b3f3fb6d1f8ff88a12bb78a65d063b 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 048/127] 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 5008325d5e5..e44251ed7c2 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 3308b8d9425..dc2b91dc1e0 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 b9c4ff1b19e..075af80ed56 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 bafe552af61be5249db8a6ae2cc267393635b7cd 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 049/127] 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 e44251ed7c2..16624f2af56 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 dc2b91dc1e0..575b05d4818 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 075af80ed56..cc895a45e9b 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 b0cbcad4405566cbfc5787c853721fb7190a4af6 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 050/127] 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 8b025f6eec8..2e0653b47e1 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 1e0d8999d30..44f88aa7339 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 575b05d4818..101a60ed54c 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 cc895a45e9b..a090ea6d725 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 @@ -1934,10 +1934,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 b054c81e1315e1091c4a34fe93a7200229a4545e Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Mon, 27 Feb 2023 05:19:29 -0500 Subject: [PATCH 051/127] 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 5ea67d3a070..c2c2485d948 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 101a60ed54c..848e5d83672 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 a090ea6d725..e7b34100db1 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 00b59c142ac801b9fd74d82907134fff45f053cd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 11:46:55 +0100 Subject: [PATCH 052/127] 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 75c37ab7b7d..fd86024fbdf 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 b3c3f9262d7..7d96d51d5ca 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 96ad5c96668def6724dc308dab3b982f4ad17a3b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 15:30:04 +0100 Subject: [PATCH 053/127] 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 c6f151b8e63..070378b3429 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 5f19f233e3f..a514760212b 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 b72224ceffee01ae425cb468dc8804e89d291609 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 27 Feb 2023 18:45:29 +0100 Subject: [PATCH 054/127] 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 9dd043a715a..e2f068b961c 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 848e5d83672..025e63c8925 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 e7b34100db1..05f9b3748fa 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 dcf1ecfeb536aff7346dc0c2ab70ea31b414a66b Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 28 Feb 2023 00:20:40 +0100 Subject: [PATCH 055/127] 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 1daffd43076..3d6fedb0706 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 08cccaf7b5f..16b1969f61b 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 025e63c8925..cf86737e944 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 05f9b3748fa..decf81c8d7c 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 9ed4e01e94a29e70dab48b60bcd275a9f8b82264 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 28 Feb 2023 00:22:22 +0100 Subject: [PATCH 056/127] 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 9bf2731b3d9..ce09032e1af 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 cf86737e944..109d88605b6 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 decf81c8d7c..8e4c697c7c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1881,7 +1881,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 72c0526d87e6bcf1ac261d268e26ea0c17a24ea0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 27 Feb 2023 20:58:22 -0500 Subject: [PATCH 057/127] Bumped version to 2023.3.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 541b5e6a967..f9a6d24e9e9 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 = 3 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 121f3726ca3..e5bcef1765b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0b4" +version = "2023.3.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2112c66804a4504684f3638712b6b1939c6c4c21 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 10:23:36 +0100 Subject: [PATCH 058/127] 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 070378b3429..b294dfa51e7 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 00000000000..0a9cf0004bc --- /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 a514760212b..7ff096795ca 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 32b138b6c6b6871f143036f49958981e3234098e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 16:19:13 +0100 Subject: [PATCH 059/127] 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 ebe2ab00257..c2020402283 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 a07819793b1..d88581696c4 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 44baf6a2d94..e41a1b18b94 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 a8e1dc8962dae72de610fda171df19562f6bfa03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 13:50:56 +0100 Subject: [PATCH 060/127] 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 c2020402283..78c5893c889 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 24fb89f2140..0a6482b040e 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 58b32276ba8..a05c3f3e926 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 16fadd9b06e..547def83450 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 109d88605b6..f733c0fcaf3 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 8e4c697c7c9..5248e53e3f9 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.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 2180a091eb7..a133f6fda30 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 d02524cb615..ac120b3e164 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 7818d736e0e..9261004ec1c 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 fc56c958c315a7a30ac9e47971c1b77d30d804e9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 17:08:45 +0100 Subject: [PATCH 061/127] 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 00aae5b8a07..0e9c8e96060 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 72884a198d8..cc3e4a9e6c3 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 d88581696c4..7c69a8d0a2d 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 a133f6fda30..d6b2a406aa1 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 ac120b3e164..368ecfe8095 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 e27cfb219cf..2ec79dcaeed 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 9261004ec1c..86443ce5c0c 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 e41a1b18b94..98432fe0beb 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 a56935ed7c607a550b2cec7954777cf963655f3c 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 062/127] 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 00000000000..d9e85a89542 --- /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 cee5b2167a8..15f191c22d1 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 86d5e4aaa8b75e9bc3ae2a3c24560b4ef0994d77 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Feb 2023 17:04:10 +0100 Subject: [PATCH 063/127] 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 85b70465854..5878f01889b 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 f9eeb4f4d89565fabad62eb05cf85d201caf037d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 28 Feb 2023 15:02:40 +0100 Subject: [PATCH 064/127] 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 a05c3f3e926..f2efea0c1e8 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 d5e517b874b15e4bb5ccf07fee376d7e54cc81df Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Feb 2023 15:50:00 +0100 Subject: [PATCH 065/127] 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 abf3e5412ef..1020879ce81 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 f58ca179265256f568db3163c323d1a519c0812d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 28 Feb 2023 17:53:15 +0100 Subject: [PATCH 066/127] 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 ce09c4c7ac9..21d0da074a7 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 7c6adc30f9e..e55bd2782df 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 538509ed5ce..466b593b56a 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 f733c0fcaf3..c9961f8e2f0 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 5248e53e3f9..c7276ac4cbd 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 cb845474327..81410b0658f 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 2238a3f201db62666d0c34cb55303d9cfe63a108 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 18:06:40 +0100 Subject: [PATCH 067/127] 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 9aafc719f1b..1e0fdfacc8e 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 e89773d9988..acf49eb5469 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 04cedab8d432ca4ba8209a81637335fea25cb7b7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Feb 2023 18:07:17 +0100 Subject: [PATCH 068/127] 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 57ae9063170..a9b32bd7f4c 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 82e8382461b..1c139a59161 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 38cf725075364dc0b4b161795c1825b9297cbd69 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 18:07:01 +0100 Subject: [PATCH 069/127] 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 1f2d83a2582..2f57d9802b9 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 b82da9418da65ab12fcae320702d53f9d7aff19c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Feb 2023 12:13:24 -0500 Subject: [PATCH 070/127] Bumped version to 2023.3.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f9a6d24e9e9..2a31d750fc1 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 = 3 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index e5bcef1765b..2c387e8b64c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0b5" +version = "2023.3.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9f3f71d0c3a3af9484e7cab6e7288a98b5855f70 Mon Sep 17 00:00:00 2001 From: Volker Stolz Date: Wed, 1 Mar 2023 03:52:45 +0100 Subject: [PATCH 071/127] 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 3e8b7bbe390..f5a954b16d4 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 8eb8415d3fc1b805186205b92a84be572d14116f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Feb 2023 21:33:50 +0100 Subject: [PATCH 072/127] 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 dca19c802b1..3da1fd841fd 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 206e575b7ac..b837cf8dfed 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 c9961f8e2f0..19b597fda8c 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 c7276ac4cbd..7d527b98a95 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 8a605b137748fc1ea5172257912ef145fb21f058 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 28 Feb 2023 15:34:07 -0500 Subject: [PATCH 073/127] 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 35d7f624c51..40316a6ba3e 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 19b597fda8c..b5b01bb3c0c 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 7d527b98a95..aa5693e93da 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 702646427d3d31037817e1bd1260c88739d8a0df 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 074/127] 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 a5bb3327332..018e8ab8135 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 b5b01bb3c0c..a947cb37df4 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 aa5693e93da..516759e0995 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 e751948bc85584ae9e78efcb4be2371e1909ff4e 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 075/127] 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 8f585879395..3c5a830698d 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 59a9ace171b6b825a04cf3c534c8246ecca6c871 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 28 Feb 2023 20:07:18 -0600 Subject: [PATCH 076/127] 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 114c71f53c8..28c19f9acd5 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 5e4e2e8902e..7630eed01f1 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 16b1969f61b..4a00b05d21b 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 a947cb37df4..1bd057d5a50 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 516759e0995..a5414ce3f0b 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 9ea95231b2f..7211f2bb9b4 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 6c73b9024bfca4164b549dffbde10ed66eb76787 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Feb 2023 22:18:39 -0500 Subject: [PATCH 077/127] Bumped version to 2023.3.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2a31d750fc1..879cce9eace 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 = 3 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 2c387e8b64c..641dda02576 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0b6" +version = "2023.3.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 89aebba3ab4205f93174b04a72516b588b870d11 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 078/127] 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 af203906b86..b248ffbac22 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 a04c74259d4..33eac82a6ba 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 62c5cf51f56bbccd6280e1cdc7522912d73eb4f7 Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Wed, 1 Mar 2023 11:34:41 +0000 Subject: [PATCH 079/127] 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 3f8fb0c6805..21ef2809360 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 a0162e4986b2ebe7e34429652077a6817e413e02 Mon Sep 17 00:00:00 2001 From: Aaron Godfrey Date: Wed, 1 Mar 2023 03:01:54 -0800 Subject: [PATCH 080/127] 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 0a822d0515d..8fdafee6cfd 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 4b55ac6859f..fece314c91c 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 fe22aa0b4b71438a5d7b50e708345d12d7683c93 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 1 Mar 2023 16:16:04 +0100 Subject: [PATCH 081/127] 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 d861c989ee0..d93e0091369 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 0b1482883aa..47c0867187e 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 5c95b4abd18..ceb20279c1f 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 c50c9205897253ecc3c7f978727b1a3c2b2ec4a6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Mar 2023 08:53:05 +0100 Subject: [PATCH 082/127] 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 a8cacc0e20d..adf91eb706b 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 9c2f68fd685..c1541812d1b 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 db4f6fb94dce27d6dea48e11d945884fa83ae5a4 Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 1 Mar 2023 10:19:46 -0500 Subject: [PATCH 083/127] 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 3677c0f8d56..9184b8c3d66 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 4b8e73e9fe7..989e6057490 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 1bd057d5a50..6f31ba81e6e 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 a5414ce3f0b..5134f0900a2 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 a46d63a11b385adc518081453f6233f22cb3b579 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Mar 2023 17:12:37 +0100 Subject: [PATCH 084/127] 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 3d6fedb0706..9cd10bb4d0a 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 4a00b05d21b..9ea8763eb99 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 6f31ba81e6e..4ef30162684 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 5134f0900a2..bf10bcc52d6 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 c146413a1aada0ad2ef90a0916178e246d2f1ba0 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 1 Mar 2023 17:10:19 +0100 Subject: [PATCH 085/127] 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 d78c546cce7..5a2ee54c5bb 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 8d59489da8eccbc9ce7265394d093a1dcea0a848 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Mar 2023 17:25:44 +0100 Subject: [PATCH 086/127] Bumped version to 2023.3.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 879cce9eace..2edef39b7c1 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 = 3 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 641dda02576..ef4253a43d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0b7" +version = "2023.3.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 243725efe334977cbaf7b2932d97f7edf06820b4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Feb 2023 13:59:16 +0100 Subject: [PATCH 087/127] 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 98432fe0beb..78935657431 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 e2e8d74aa68bca0a67f0702cc2f500af3df5cdfc Mon Sep 17 00:00:00 2001 From: Toni Juvani Date: Thu, 2 Mar 2023 17:11:34 +0200 Subject: [PATCH 088/127] 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 4d9c0560682..6bd68e17c4d 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 d0adc0391ab..b5cb4486cc9 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 0e23729df72..e716192b8b4 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 5f375ee22ed..7c563208720 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 4ef30162684..940e0f44453 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 bf10bcc52d6..c627d49699c 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 c28e16fa8b0a5d99aed5c46d8d0b59a7987c06e0 Mon Sep 17 00:00:00 2001 From: Mitch Date: Wed, 1 Mar 2023 16:17:55 +0100 Subject: [PATCH 089/127] 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 9ea8763eb99..fafaf00f93f 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 ef4253a43d6..13f27aae435 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", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", diff --git a/requirements.txt b/requirements.txt index aa6e85d1520..114d9acbcde 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 voluptuous==0.13.1 voluptuous-serialize==2.6.0 From 3e961d3e174e5b3840f6d6320c86a4730382ed0f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Mar 2023 03:07:12 +0100 Subject: [PATCH 090/127] 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 b837cf8dfed..7a4f6b9d905 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 940e0f44453..b1f1d83cd76 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 c627d49699c..0fa288700a5 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 f7eaeb7a398b807c2945af8f1e3d08bd9078be1e Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 2 Mar 2023 16:13:02 +0100 Subject: [PATCH 091/127] 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 ec79dbc5f9a..85e23cbe547 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 4ac6a366119..054d7844714 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 a867f1d3c89dd48885356ccea1b7d7da1be5cf1d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 2 Mar 2023 15:40:46 +0100 Subject: [PATCH 092/127] 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 fafaf00f93f..eb05738c9aa 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 13f27aae435..3e4f8d23bdf 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 114d9acbcde..68d6bceb244 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 d0b195516bd546ca583f9a4fd1c1d0cef8789a83 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 2 Mar 2023 16:10:26 +0100 Subject: [PATCH 093/127] 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 9cd10bb4d0a..c09f2d501c6 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 eb05738c9aa..3a199853634 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 b1f1d83cd76..e97294a3cb8 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 0fa288700a5..3e3e0629a87 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 b1ee6e304ec9f74ccb3c028b9b2792f54deff6ab Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 2 Mar 2023 20:20:26 +0100 Subject: [PATCH 094/127] 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 fd86024fbdf..1812f41693d 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 7d96d51d5ca..8be15f1c7cd 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 4eb55146beb0840b964c790c8f11f05d11141d4b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Mar 2023 14:22:23 -0500 Subject: [PATCH 095/127] Bumped version to 2023.3.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2edef39b7c1..1ec896a415f 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 = 3 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 3e4f8d23bdf..1a81cc5f502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.0" +version = "2023.3.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 1836e35717d50469fc535aad691455c93c60a153 Mon Sep 17 00:00:00 2001 From: Mitch Date: Wed, 1 Mar 2023 11:39:14 +0100 Subject: [PATCH 096/127] 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 91b0a9eb194..cda1e9b02dd 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 e97294a3cb8..d11e4d66026 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 3e3e0629a87..22d6a64361e 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 322eb4bd8315cae4a11f9fa5b43a21c12602ea57 Mon Sep 17 00:00:00 2001 From: Andrew Westrope Date: Sun, 5 Mar 2023 12:51:02 +0000 Subject: [PATCH 097/127] 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 21ef2809360..c2b32582cef 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 cf29d0ea802..79ba418d509 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 ea8b1a43961..f8cf7288e57 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 d4c28a1f4a8c08a8fe5d4a5c81da9686fc7589fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Mar 2023 16:31:12 -1000 Subject: [PATCH 098/127] 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 56a07a6bcf0..5c0a60ecef7 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 c7fc375008a..90e244aaf06 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 e8008eb49b6..fa5c6aac294 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 570bb980aba..61da000fc07 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 c27a69ef85055ed5d445ab797f03313ede8f87fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Mar 2023 17:00:13 -1000 Subject: [PATCH 099/127] 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 431bc78ba80..0b8fe9243ba 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 294c5217623..c90447f1c99 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 3ff6b62b21e..bfdd8ff5b14 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 44c3ffac99e..19c7e6c6955 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 c5ce8d272c7..07c935129e9 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 8685985def8..dd51946c86f 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 8252aeead259b399a366c507f72e5c7954b9416d Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 4 Mar 2023 12:05:41 +0100 Subject: [PATCH 100/127] 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 6522d3b06ed..6dd2104bd9b 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 d11e4d66026..35de4ee44bd 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 22d6a64361e..27147b8fc32 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 c9999cd08c7d9b1264cc054e1827b938a918333c Mon Sep 17 00:00:00 2001 From: MarkGodwin Date: Mon, 6 Mar 2023 04:47:45 +0000 Subject: [PATCH 101/127] 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 6b958b7d258..f6a75abe6d8 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 fd32b357b7c..cf3fddf5943 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 1a0a385e03b73159c652abb9f87fd8a33b93138e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 5 Mar 2023 22:19:40 +0100 Subject: [PATCH 102/127] 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 1a2d0c526d0..ffc00e6f92c 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 eba1bfad51a7c954b4caa5bf558df85211c246e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 07:49:54 -1000 Subject: [PATCH 103/127] 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 fde8c26ba5e..e8e4e4876f0 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 35de4ee44bd..b7b3d5ac6ff 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 27147b8fc32..b9110a0bed2 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 e3fe71f76e8d6265e50a058bb175749f05d14772 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 6 Mar 2023 12:47:01 +0100 Subject: [PATCH 104/127] 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 c09f2d501c6..da68e48cc08 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 3a199853634..2c6712aa750 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 b7b3d5ac6ff..5c1f91cd73c 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 b9110a0bed2..a18d25652ea 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 0fac12866dac8e951a3a7960b43fe660b441ffcd 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 105/127] 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 bd629d53fc6..119c7c946a5 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 d737b97c916f7ebea715bdbd1ad800202a47a554 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 15:20:37 -1000 Subject: [PATCH 106/127] 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 f40f866808c..ed885127b1b 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 e3efa81e44a..bdedbb9b207 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 2c6712aa750..4e4786e7edc 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 voluptuous-serialize==2.6.0 voluptuous==0.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 5c1f91cd73c..ac63cec4b4e 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 a18d25652ea..d0900301924 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1698,7 +1698,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 8d1aa0132e4cd82c2bacdda7402cc83c01ebbe9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Mar 2023 15:44:11 -1000 Subject: [PATCH 107/127] 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 fb1a55cebfb..b67790f9a42 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 c90447f1c99..bd11744ab09 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 dd51946c86f..e6ae291264f 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 0b5ddd9cbfe2e32476a4b205ce98832efbc14f27 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 108/127] 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 cb559a7d7bb..e782f050926 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1100,6 +1100,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 d69f06f6983..bdcadc84e7c 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 ac63cec4b4e..1df6d464676 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 f982af2412fbc91c64475209b39f13643d0ee23c 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 109/127] 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 07f122fa4b2..4873acf753e 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 f84441d2491..5f4aadce7e2 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 74d3b2374be106c83e0ce745ecd682af5aa70031 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 110/127] 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 d32dcf0bda6..d0496fe7b60 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 e580242a677..a92631f6da3 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 52cd2f942958afdad5c6a74a433a87357ca50ebc Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 7 Mar 2023 21:06:29 -0500 Subject: [PATCH 111/127] 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 40316a6ba3e..743e7e4fa19 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 c5dbba9c25b..0df823e49b1 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 1df6d464676..fb17dc6d4da 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 d0900301924..bd5fe240e34 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 83ed8cf689276721c5b4f4e94b22c844580defb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Mar 2023 15:21:26 -1000 Subject: [PATCH 112/127] 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 b945f818d00..eb1e2a5ef68 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 1006fa374c3..a551315205b 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 863f8b727d11442d01fc3b3903251955db271f18 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Wed, 8 Mar 2023 09:00:40 +0000 Subject: [PATCH 113/127] 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 e46893e8d79..a47c308279d 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 29b5ef31c18617d51948a76b10719b9098402a03 Mon Sep 17 00:00:00 2001 From: Florent Thoumie Date: Wed, 8 Mar 2023 06:37:24 -0800 Subject: [PATCH 114/127] 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 cbdf909001a..225953035a2 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 b047af5869c..87bc863a7f8 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 563bd4a0dd6e19f43026f8352514a55cb6fd2326 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Mar 2023 04:27:34 -1000 Subject: [PATCH 115/127] 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 00cc9fff0fe..903f14a9227 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 7f8a9697f0104839189a500019697c6271e2f5f3 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Mar 2023 12:25:51 +0100 Subject: [PATCH 116/127] 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 e9b692fffe6..7c807ddadc3 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -64,7 +64,7 @@ NUMBER_ENTITIES = ( get_max_value=lambda api, ch: api.zoom_range(ch)["focus"]["pos"]["max"], supported=lambda api, ch: api.zoom_supported(ch), 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)), ), ) From b65180d20a45fa39d46593b5977091a5f2c3941c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Mar 2023 15:21:11 +0100 Subject: [PATCH 117/127] 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 64ef7a718a5..2710e146540 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 0d923075bf7..762df4f79ca 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 2b7145bdcaa..8a8583a7daf 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 dceff75bca8..334c7cf719c 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 3670d5ca1fd..8a9a145f2d6 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 a6cd956c95e..78ae9643d68 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 ee23d5d350e..64e9e1c31cc 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 8ef6fa4001b..cb1dd639ec6 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 52ca535516a..67548a19c2c 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 611ada61814..b2f9e06cb43 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 0844a0b269fb9b3b9d2b96dd883fb4b4b66d5714 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 118/127] 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 4c63f1c3fa8..e7aed366fa3 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 3f8f38f2dfd9e851954acf3561d61783c8340917 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Mar 2023 16:24:08 +0100 Subject: [PATCH 119/127] Bumped version to 2023.3.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1ec896a415f..e90ccad63e5 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 = 3 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 1a81cc5f502..34e88267645 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.1" +version = "2023.3.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ebf95feff33f98e90e5e39a0fb8c61732b371d8d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 9 Mar 2023 08:02:59 +0100 Subject: [PATCH 120/127] 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 153726a89e8..358a97ed30d 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 1e486a3492c..fcdec1fbfe3 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 e606c2e2270ba6bdcd0faa2fba6bd3d215498d03 Mon Sep 17 00:00:00 2001 From: Dillon Fearns Date: Wed, 8 Mar 2023 21:39:33 +0000 Subject: [PATCH 121/127] 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 5aa630df5d0..08815cae9fb 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 fb17dc6d4da..0da8822ad18 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.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd5fe240e34..6e0e8ac3313 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,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.3 From 04e4a644cb1532d5297fe876aad4f0aa93ccfb10 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Wed, 8 Mar 2023 15:56:40 -0500 Subject: [PATCH 122/127] 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 64bb8bef0c0..2c2aafa960e 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 0da8822ad18..ca8360f2e81 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 6e0e8ac3313..853e1e0d9a8 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 f1e114380a26e24dfe5d757b694940d644a93587 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 8 Mar 2023 22:35:06 +0100 Subject: [PATCH 123/127] 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 df51dd60a15..1eabb509abd 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 if (payload_datetime := dt_util.parse_datetime(new_value)) is None: diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 09944b56c04..cee3aae375a 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -135,6 +135,8 @@ async def test_setting_sensor_value_via_mqtt_message( False, ), (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 c7fb404a1719772761be88d8f612d4a326afc67a 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 124/127] 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 8a8583a7daf..fecf05f74b4 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 cb1dd639ec6..e659fbe4b8f 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 83e2cc32b72f54c8254f274673e2c051382d6bb2 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 9 Mar 2023 18:28:02 +0100 Subject: [PATCH 125/127] 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 da68e48cc08..a4d97201c5f 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 4e4786e7edc..0d3be634e77 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 ca8360f2e81..33e6d70f7e7 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 853e1e0d9a8..e2f1b0f7513 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 da79bf8534090835f4e9ea260266848c56ae7584 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Mar 2023 19:17:29 +0100 Subject: [PATCH 126/127] 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 95e26a3eeb3..e21e35da1e5 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 3fba181e7b486155a71977e6e60de0d36e4b81f1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Mar 2023 13:30:46 -0500 Subject: [PATCH 127/127] Bumped version to 2023.3.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e90ccad63e5..82b9fd1a31b 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 = 3 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) diff --git a/pyproject.toml b/pyproject.toml index 34e88267645..0dcc14344ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.3.2" +version = "2023.3.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"